Merge pull request #20 from calcorum/fix/draft-pick-api-parsing

Draft System Enhancements: Skipped Picks, Google Sheets, Pause/Resume
This commit is contained in:
Cal Corum 2025-12-11 20:26:01 -06:00 committed by GitHub
commit 5d393f4f53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 3606 additions and 134 deletions

View File

@ -49,9 +49,29 @@ 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
### Skipped Pick Support
- **Purpose**: Allow teams to make up picks they missed when not on the clock
- **Detection**: Checks for picks with `overall < current_overall` and `player_id = None`
- **Behavior**: If team is not on the clock but has skipped picks, allows drafting with earliest skipped pick
- **User Experience**: Success message includes footer noting this is a "skipped pick makeup"
- **Draft Advancement**: Does NOT advance the draft when using a skipped pick
```python
# Skipped pick detection flow
if current_pick.owner.id != team.id:
skipped_picks = await draft_pick_service.get_skipped_picks_for_team(
season, team.id, current_overall
)
if skipped_picks:
pick_to_use = skipped_picks[0] # Earliest skipped pick
else:
# Return "Not Your Turn" error
```
### Global Pick Lock ### Global Pick Lock
- **Purpose**: Prevent concurrent draft picks that could cause race conditions - **Purpose**: Prevent concurrent draft picks that could cause race conditions
- **Implementation**: `asyncio.Lock()` stored in cog instance - **Implementation**: `asyncio.Lock()` stored in cog instance
@ -87,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:
@ -122,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
@ -217,36 +325,49 @@ The draft monitor task (`tasks/draft_monitor.py`) integrates with this command:
3. **Draft List**: Monitor tries players from team's draft list in order 3. **Draft List**: Monitor tries players from team's draft list in order
4. **Pick Advancement**: Monitor calls same `draft_service.advance_pick()` 4. **Pick Advancement**: Monitor calls same `draft_service.advance_pick()`
## Future Commands ## Implemented Commands
### `/draft-status` (Pending Implementation) ### `/draft-status`
Display current draft state, timer, lock status Display current draft state, timer, lock status
### `/draft-admin` (Pending Implementation) ### `/draft-admin` (Administrator Only)
Admin controls: Admin controls:
- Timer on/off - `/draft-admin timer` - Enable/disable timer (auto-starts monitor task)
- Set current pick - `/draft-admin set-pick` - Set current pick (auto-starts monitor if timer active)
- Configure channels - `/draft-admin channels` - Configure ping/result channels
- Wipe picks - `/draft-admin wipe` - Clear all picks for season
- Clear stale locks - `/draft-admin info` - View detailed draft configuration
- Set keepers - `/draft-admin resync-sheet` - Resync all picks to Google Sheet
### `/draft-list` (Pending Implementation) ### `/draft-list`
Manage auto-draft queue: View auto-draft queue for your team
- View current list
- Add players
- Remove players
- Reorder players
- Clear list
### `/draft-board` (Pending Implementation) ### `/draft-list-add`
Add player to auto-draft queue
### `/draft-list-remove`
Remove player from auto-draft queue
### `/draft-list-clear`
Clear entire auto-draft queue
### `/draft-board`
View draft board by round with pagination View draft board by round with pagination
### `/draft-on-clock`
View detailed on-the-clock information including:
- Current team on the clock
- Deadline with relative timestamp
- Team's current sWAR and cap space
- Last 5 picks
- Top 5 roster players by sWAR
## Dependencies ## Dependencies
- `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`
@ -288,6 +409,6 @@ Test scenarios:
--- ---
**Last Updated:** October 2025 **Last Updated:** December 2025
**Status:** Core `/draft` command implemented and tested **Status:** All draft commands implemented and tested
**Next:** Implement `/draft-status`, `/draft-admin`, `/draft-list` commands **Recent:** Google Sheets integration for automatic pick tracking, `/draft-admin resync-sheet` command, sheet link in `/draft-status`

View File

@ -56,7 +56,7 @@ async def setup_draft(bot: commands.Bot):
# Load draft admin group (app_commands.Group pattern) # Load draft admin group (app_commands.Group pattern)
try: try:
bot.tree.add_command(DraftAdminGroup()) bot.tree.add_command(DraftAdminGroup(bot))
logger.info("✅ Loaded DraftAdminGroup") logger.info("✅ Loaded DraftAdminGroup")
successful += 1 successful += 1
except Exception as e: except Exception as e:

View File

@ -12,6 +12,7 @@ from discord.ext import commands
from config import get_config from config import get_config
from services.draft_service import draft_service from services.draft_service import draft_service
from services.draft_pick_service import draft_pick_service from services.draft_pick_service import draft_pick_service
from services.draft_sheet_service import get_draft_sheet_service
from utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
from utils.decorators import logged_command from utils.decorators import logged_command
from utils.permissions import league_admin_only from utils.permissions import league_admin_only
@ -22,13 +23,35 @@ from views.embeds import EmbedTemplate
class DraftAdminGroup(app_commands.Group): class DraftAdminGroup(app_commands.Group):
"""Draft administration command group.""" """Draft administration command group."""
def __init__(self): def __init__(self, bot: commands.Bot):
super().__init__( super().__init__(
name="draft-admin", name="draft-admin",
description="Admin commands for draft management" description="Admin commands for draft management"
) )
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.DraftAdminGroup') self.logger = get_contextual_logger(f'{__name__}.DraftAdminGroup')
def _ensure_monitor_running(self) -> str:
"""
Ensure the draft monitor task is running.
Returns:
Status message about the monitor state
"""
from tasks.draft_monitor import setup_draft_monitor
if not hasattr(self.bot, 'draft_monitor') or self.bot.draft_monitor is None:
self.bot.draft_monitor = setup_draft_monitor(self.bot)
self.logger.info("Draft monitor task started")
return "\n\n🤖 **Draft monitor started** - auto-draft and warnings active"
elif not self.bot.draft_monitor.monitor_loop.is_running():
# Task exists but was stopped/cancelled - create a new one
self.bot.draft_monitor = setup_draft_monitor(self.bot)
self.logger.info("Draft monitor task recreated")
return "\n\n🤖 **Draft monitor restarted** - auto-draft and warnings active"
else:
return "\n\n🤖 Draft monitor already running"
@app_commands.command(name="info", description="View current draft configuration") @app_commands.command(name="info", description="View current draft configuration")
@league_admin_only() @league_admin_only()
@logged_command("/draft-admin info") @logged_command("/draft-admin info")
@ -53,8 +76,11 @@ class DraftAdminGroup(app_commands.Group):
draft_data.currentpick draft_data.currentpick
) )
# Get sheet URL
sheet_url = config.get_draft_sheet_url(config.sba_season)
# Create admin info embed # Create admin info embed
embed = await create_admin_draft_info_embed(draft_data, current_pick) embed = await create_admin_draft_info_embed(draft_data, current_pick, sheet_url)
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
@app_commands.command(name="timer", description="Enable or disable draft timer") @app_commands.command(name="timer", description="Enable or disable draft timer")
@ -94,14 +120,29 @@ class DraftAdminGroup(app_commands.Group):
await interaction.followup.send(embed=embed, ephemeral=True) await interaction.followup.send(embed=embed, ephemeral=True)
return return
# Start draft monitor task if timer is enabled
monitor_status = ""
if enabled:
monitor_status = self._ensure_monitor_running()
# Success message # Success message
status = "enabled" if enabled else "disabled" status = "enabled" if enabled else "disabled"
description = f"Draft timer has been **{status}**." description = f"Draft timer has been **{status}**."
if enabled and minutes: if enabled:
description += f"\n\nPick duration: **{minutes} minutes**" # Show pick duration
elif enabled: pick_mins = minutes if minutes else updated.pick_minutes
description += f"\n\nPick duration: **{updated.pick_minutes} minutes**" description += f"\n\n**Pick duration:** {pick_mins} minutes"
# Show current pick number
description += f"\n**Current Pick:** #{updated.currentpick}"
# Show deadline
if updated.pick_deadline:
deadline_timestamp = int(updated.pick_deadline.timestamp())
description += f"\n**Deadline:** <t:{deadline_timestamp}:T> (<t:{deadline_timestamp}:R>)"
description += monitor_status
embed = EmbedTemplate.success("Timer Updated", description) embed = EmbedTemplate.success("Timer Updated", description)
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
@ -173,6 +214,16 @@ class DraftAdminGroup(app_commands.Group):
if pick.owner: if pick.owner:
description += f"\n\n{pick.owner.abbrev} {pick.owner.sname} is now on the clock." description += f"\n\n{pick.owner.abbrev} {pick.owner.sname} is now on the clock."
# Add timer status and ensure monitor is running if timer is active
if updated.timer and updated.pick_deadline:
deadline_timestamp = int(updated.pick_deadline.timestamp())
description += f"\n\n⏱️ **Timer Active** - Deadline <t:{deadline_timestamp}:R>"
# Ensure monitor is running
monitor_status = self._ensure_monitor_running()
description += monitor_status
else:
description += "\n\n⏸️ **Timer Inactive**"
embed = EmbedTemplate.success("Pick Updated", description) embed = EmbedTemplate.success("Pick Updated", description)
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
@ -288,7 +339,226 @@ 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="pause", description="Pause the draft (block all picks)")
@league_admin_only()
@logged_command("/draft-admin pause")
async def draft_admin_pause(self, interaction: discord.Interaction):
"""Pause the draft, blocking all manual and auto-draft picks."""
await interaction.response.defer()
# Get draft data
draft_data = await draft_service.get_draft_data()
if not draft_data:
embed = EmbedTemplate.error(
"Draft Not Found",
"Could not retrieve draft configuration."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Check if already paused
if draft_data.paused:
embed = EmbedTemplate.warning(
"Already Paused",
"The draft is already paused."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Pause the draft
updated = await draft_service.pause_draft(draft_data.id)
if not updated:
embed = EmbedTemplate.error(
"Pause Failed",
"Failed to pause the draft."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Success message
description = (
"The draft has been **paused**.\n\n"
"**Effects:**\n"
"• All `/draft` picks are blocked\n"
"• Auto-draft will not fire\n"
"• Timer has been stopped\n\n"
"Use `/draft-admin resume` to restart the timer and allow picks."
)
embed = EmbedTemplate.warning("Draft Paused", description)
await interaction.followup.send(embed=embed)
@app_commands.command(name="resume", description="Resume the draft (allow picks)")
@league_admin_only()
@logged_command("/draft-admin resume")
async def draft_admin_resume(self, interaction: discord.Interaction):
"""Resume the draft, allowing manual and auto-draft picks again."""
await interaction.response.defer()
# Get draft data
draft_data = await draft_service.get_draft_data()
if not draft_data:
embed = EmbedTemplate.error(
"Draft Not Found",
"Could not retrieve draft configuration."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Check if already unpaused
if not draft_data.paused:
embed = EmbedTemplate.warning(
"Not Paused",
"The draft is not currently paused."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Resume the draft
updated = await draft_service.resume_draft(draft_data.id)
if not updated:
embed = EmbedTemplate.error(
"Resume Failed",
"Failed to resume the draft."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Build success message
description = "The draft has been **resumed**.\n\nPicks are now allowed."
# Add timer info if active
if updated.timer and updated.pick_deadline:
deadline_timestamp = int(updated.pick_deadline.timestamp())
description += f"\n\n⏱️ **Timer Active** - Current deadline <t:{deadline_timestamp}:R>"
# Ensure monitor is running
monitor_status = self._ensure_monitor_running()
description += monitor_status
embed = EmbedTemplate.success("Draft Resumed", 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): async def setup(bot: commands.Bot):
"""Setup function for loading the draft admin commands.""" """Setup function for loading the draft admin commands."""
bot.tree.add_command(DraftAdminGroup()) bot.tree.add_command(DraftAdminGroup(bot))

View File

@ -71,8 +71,11 @@ class DraftBoardCommands(commands.Cog):
await interaction.followup.send(embed=embed, ephemeral=True) await interaction.followup.send(embed=embed, ephemeral=True)
return return
# Get sheet URL
sheet_url = config.get_draft_sheet_url(config.sba_season)
# Create draft board embed # Create draft board embed
embed = await create_draft_board_embed(round_number, picks) embed = await create_draft_board_embed(round_number, picks, sheet_url)
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)

View File

@ -266,9 +266,17 @@ class DraftListCommands(commands.Cog):
await interaction.followup.send(embed=embed, ephemeral=True) await interaction.followup.send(embed=embed, ephemeral=True)
return return
# Success message # Get updated list
description = f"Removed **{player_obj.name}** from your draft queue." updated_list = await draft_list_service.get_team_list(
embed = EmbedTemplate.success("Player Removed", description) config.sba_season,
team.id
)
# Success message with full draft list
success_msg = f"✅ Removed **{player_obj.name}** from your draft queue."
embed = await create_draft_list_embed(team, updated_list)
embed.description = f"{success_msg}\n\n{embed.description}"
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
@discord.app_commands.command( @discord.app_commands.command(
@ -329,6 +337,7 @@ class DraftListCommands(commands.Cog):
# Success message # Success message
description = f"Cleared **{len(current_list)} players** from your draft queue." description = f"Cleared **{len(current_list)} players** from your draft queue."
embed = EmbedTemplate.success("Queue Cleared", description) embed = EmbedTemplate.success("Queue Cleared", description)
embed.set_footer(text="Use /draft-list-add to build your queue")
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)

View File

@ -13,6 +13,7 @@ from discord.ext import commands
from config import get_config from config import get_config
from services.draft_service import draft_service from services.draft_service import draft_service
from services.draft_pick_service import draft_pick_service from services.draft_pick_service import draft_pick_service
from services.draft_sheet_service import get_draft_sheet_service
from services.player_service import player_service from services.player_service import player_service
from services.team_service import team_service from services.team_service import team_service
from utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
@ -159,6 +160,15 @@ class DraftPicksCog(commands.Cog):
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
# Check if draft is paused
if draft_data.paused:
embed = await create_pick_illegal_embed(
"Draft Paused",
"The draft is currently paused. Please wait for an administrator to resume."
)
await interaction.followup.send(embed=embed)
return
# Get current pick # Get current pick
current_pick = await draft_pick_service.get_pick( current_pick = await draft_pick_service.get_pick(
config.sba_season, config.sba_season,
@ -173,15 +183,32 @@ class DraftPicksCog(commands.Cog):
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
# Validate user is on the clock # Validate user is on the clock OR has a skipped pick
pick_to_use = current_pick # Default: use current pick if on the clock
if current_pick.owner.id != team.id: if current_pick.owner.id != team.id:
# TODO: Check for skipped picks # Not on the clock - check for skipped picks
embed = await create_pick_illegal_embed( skipped_picks = await draft_pick_service.get_skipped_picks_for_team(
"Not Your Turn", config.sba_season,
f"{current_pick.owner.sname} is on the clock for {format_pick_display(current_pick.overall)}." team.id,
draft_data.currentpick
)
if not skipped_picks:
# No skipped picks - can't draft
embed = await create_pick_illegal_embed(
"Not Your Turn",
f"{current_pick.owner.sname} is on the clock for {format_pick_display(current_pick.overall)}."
)
await interaction.followup.send(embed=embed)
return
# Use the earliest skipped pick
pick_to_use = skipped_picks[0]
self.logger.info(
f"Team {team.abbrev} using skipped pick #{pick_to_use.overall} "
f"(current pick is #{current_pick.overall})"
) )
await interaction.followup.send(embed=embed)
return
# Get player # Get player
players = await player_service.get_players_by_name(player_name, config.sba_season) players = await player_service.get_players_by_name(player_name, config.sba_season)
@ -215,19 +242,19 @@ class DraftPicksCog(commands.Cog):
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
is_valid, projected_total = await validate_cap_space(roster, player_obj.wara) is_valid, projected_total, cap_limit = await validate_cap_space(roster, player_obj.wara, team)
if not is_valid: if not is_valid:
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Cap Space Exceeded", "Cap Space Exceeded",
f"Drafting {player_obj.name} would put you at {projected_total:.2f} sWAR (limit: {config.swar_cap_limit:.2f})." f"Drafting {player_obj.name} would put you at {projected_total:.2f} sWAR (limit: {cap_limit:.2f})."
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
# Execute pick # Execute pick (using pick_to_use which may be current or skipped pick)
updated_pick = await draft_pick_service.update_pick_selection( updated_pick = await draft_pick_service.update_pick_selection(
current_pick.id, pick_to_use.id,
player_obj.id player_obj.id
) )
@ -248,32 +275,145 @@ 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
is_skipped_pick = pick_to_use.overall != current_pick.overall
# Send success message # Send success message
success_embed = await create_pick_success_embed( success_embed = await create_pick_success_embed(
player_obj, player_obj,
team, team,
current_pick.overall, pick_to_use.overall,
projected_total projected_total,
cap_limit
) )
# Add note if this was a skipped pick
if is_skipped_pick:
success_embed.set_footer(
text=f"📝 Making up skipped pick (current pick is #{current_pick.overall})"
)
await interaction.followup.send(embed=success_embed) await interaction.followup.send(embed=success_embed)
# Post draft card to ping channel # Post draft card to ping channel (only if different from command channel)
if draft_data.ping_channel: if draft_data.ping_channel and draft_data.ping_channel != interaction.channel_id:
guild = interaction.guild guild = interaction.guild
if guild: if guild:
ping_channel = guild.get_channel(draft_data.ping_channel) ping_channel = guild.get_channel(draft_data.ping_channel)
if ping_channel: if ping_channel:
draft_card = await create_player_draft_card(player_obj, current_pick) draft_card = await create_player_draft_card(player_obj, pick_to_use)
# Add skipped pick context to draft card
if is_skipped_pick:
draft_card.set_footer(
text=f"📝 Making up skipped pick (current pick is #{current_pick.overall})"
)
await ping_channel.send(embed=draft_card) await ping_channel.send(embed=draft_card)
# Advance to next pick # Only advance the draft if this was the current pick (not a skipped pick)
await draft_service.advance_pick(draft_data.id, draft_data.currentpick) if not is_skipped_pick:
await draft_service.advance_pick(draft_data.id, draft_data.currentpick)
self.logger.info( self.logger.info(
f"Draft pick completed: {team.abbrev} selected {player_obj.name} " f"Draft pick completed: {team.abbrev} selected {player_obj.name} "
f"(pick #{current_pick.overall})" f"(pick #{pick_to_use.overall})"
+ (f" [skipped pick makeup]" if is_skipped_pick else "")
) )
async def _write_pick_to_sheets(
self,
draft_data,
pick,
player,
team,
guild: Optional[discord.Guild]
):
"""
Write pick to Google Sheets (fire-and-forget with ping channel notification on failure).
Args:
draft_data: Current draft configuration
pick: The draft pick being used
player: Player being drafted
team: Team making the pick
guild: Discord guild for notification channel
"""
config = get_config()
try:
draft_sheet_service = get_draft_sheet_service()
success = await draft_sheet_service.write_pick(
season=config.sba_season,
overall=pick.overall,
orig_owner_abbrev=pick.origowner.abbrev if pick.origowner else team.abbrev,
owner_abbrev=team.abbrev,
player_name=player.name,
swar=player.wara
)
if not success:
# Write failed - notify in ping channel
await self._notify_sheet_failure(
guild=guild,
channel_id=draft_data.ping_channel,
pick_overall=pick.overall,
player_name=player.name,
reason="Sheet write returned failure"
)
except Exception as e:
self.logger.warning(f"Failed to write pick to sheets: {e}")
# Notify in ping channel
await self._notify_sheet_failure(
guild=guild,
channel_id=draft_data.ping_channel,
pick_overall=pick.overall,
player_name=player.name,
reason=str(e)
)
async def _notify_sheet_failure(
self,
guild: Optional[discord.Guild],
channel_id: Optional[int],
pick_overall: int,
player_name: str,
reason: str
):
"""
Post notification to ping channel when sheet write fails.
Args:
guild: Discord guild
channel_id: Ping channel ID
pick_overall: Pick number that failed
player_name: Player name
reason: Failure reason
"""
if not guild or not channel_id:
return
try:
channel = guild.get_channel(channel_id)
if channel and hasattr(channel, 'send'):
await channel.send(
f"⚠️ **Sheet Sync Failed** - Pick #{pick_overall} ({player_name}) "
f"was not written to the draft sheet. "
f"Use `/draft-admin resync-sheet` to manually sync."
)
except Exception as e:
self.logger.error(f"Failed to send sheet failure notification: {e}")
async def setup(bot: commands.Bot): async def setup(bot: commands.Bot):
"""Load the draft picks cog.""" """Load the draft picks cog."""

View File

@ -71,8 +71,11 @@ class DraftStatusCommands(commands.Cog):
else: else:
lock_status = "🔒 Pick in progress (system)" lock_status = "🔒 Pick in progress (system)"
# Get draft sheet URL
sheet_url = config.get_draft_sheet_url(config.sba_season)
# Create status embed # Create status embed
embed = await create_draft_status_embed(draft_data, current_pick, lock_status) embed = await create_draft_status_embed(draft_data, current_pick, lock_status, sheet_url)
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
@discord.app_commands.command( @discord.app_commands.command(
@ -133,13 +136,17 @@ class DraftStatusCommands(commands.Cog):
if roster and roster.get('active'): if roster and roster.get('active'):
team_roster_swar = roster['active'].get('WARa') team_roster_swar = roster['active'].get('WARa')
# Get sheet URL
sheet_url = config.get_draft_sheet_url(config.sba_season)
# Create on the clock embed # Create on the clock embed
embed = await create_on_the_clock_embed( embed = await create_on_the_clock_embed(
current_pick, current_pick,
draft_data, draft_data,
recent_picks, recent_picks,
upcoming_picks, upcoming_picks,
team_roster_swar team_roster_swar,
sheet_url
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)

View File

@ -608,9 +608,10 @@ class ChartCategoryGroup(app_commands.Group):
categories = self.chart_service.get_categories() categories = self.chart_service.get_categories()
if not categories: if not categories:
embed = EmbedTemplate.info( embed = EmbedTemplate.create_base_embed(
title="📊 Chart Categories", title="📊 Chart Categories",
description="No categories defined. Use `/chart-categories add` to create one." description="No categories defined. Use `/chart-categories add` to create one.",
color=EmbedColors.INFO
) )
await interaction.response.send_message(embed=embed, ephemeral=True) await interaction.response.send_message(embed=embed, ephemeral=True)
return return

View File

@ -1,6 +1,9 @@
""" """
Configuration management for Discord Bot v2.0 Configuration management for Discord Bot v2.0
""" """
import os
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Baseball position constants (static, not configurable) # Baseball position constants (static, not configurable)
@ -84,6 +87,12 @@ class BotConfig(BaseSettings):
# Google Sheets settings # Google Sheets settings
sheets_credentials_path: str = "/app/data/major-domo-service-creds.json" sheets_credentials_path: str = "/app/data/major-domo-service-creds.json"
# Draft Sheet settings (for writing picks to Google Sheets)
# Sheet IDs can be overridden via environment variables: DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc.
draft_sheet_enabled: bool = True # Feature flag - set DRAFT_SHEET_ENABLED=false to disable
draft_sheet_worksheet: str = "Ordered List" # Worksheet name to write picks to
draft_sheet_start_column: str = "D" # Column where pick data starts (D, E, F, G for 4 columns)
# Giphy API settings # Giphy API settings
giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD" giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD"
giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate" giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate"
@ -113,6 +122,42 @@ class BotConfig(BaseSettings):
"""Calculate total picks in draft (derived value).""" """Calculate total picks in draft (derived value)."""
return self.draft_rounds * self.draft_team_count return self.draft_rounds * self.draft_team_count
def get_draft_sheet_key(self, season: int) -> Optional[str]:
"""
Get the Google Sheet ID for a given draft season.
Sheet IDs are configured via environment variables:
- DRAFT_SHEET_KEY_12 for season 12
- DRAFT_SHEET_KEY_13 for season 13
- etc.
Returns None if no sheet is configured for the season.
"""
# Default sheet IDs (hardcoded as fallback)
default_keys = {
12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU",
13: "1vWJfvuz9jN5BU2ZR0X0oC9BAVr_R8o-dWZsF2KXQMsE"
}
# Check environment variable first (allows runtime override)
env_key = os.getenv(f"DRAFT_SHEET_KEY_{season}")
if env_key:
return env_key
# Fall back to hardcoded default
return default_keys.get(season)
def get_draft_sheet_url(self, season: int) -> Optional[str]:
"""
Get the full Google Sheets URL for a given draft season.
Returns None if no sheet is configured for the season.
"""
sheet_key = self.get_draft_sheet_key(season)
if sheet_key:
return f"https://docs.google.com/spreadsheets/d/{sheet_key}"
return None
# Global configuration instance - lazily initialized to avoid import-time errors # Global configuration instance - lazily initialized to avoid import-time errors
_config = None _config = None

View File

@ -15,6 +15,7 @@ class DraftData(SBABaseModel):
currentpick: int = Field(0, description="Current pick number in progress") currentpick: int = Field(0, description="Current pick number in progress")
timer: bool = Field(False, description="Whether draft timer is active") timer: bool = Field(False, description="Whether draft timer is active")
paused: bool = Field(False, description="Whether draft is paused (blocks all picks)")
pick_deadline: Optional[datetime] = Field(None, description="Deadline for current pick") pick_deadline: Optional[datetime] = Field(None, description="Deadline for current pick")
result_channel: Optional[int] = Field(None, description="Discord channel ID for draft results") result_channel: Optional[int] = Field(None, description="Discord channel ID for draft results")
ping_channel: Optional[int] = Field(None, description="Discord channel ID for draft pings") ping_channel: Optional[int] = Field(None, description="Discord channel ID for draft pings")
@ -32,16 +33,26 @@ class DraftData(SBABaseModel):
@property @property
def is_draft_active(self) -> bool: def is_draft_active(self) -> bool:
"""Check if the draft is currently active.""" """Check if the draft is currently active (timer running and not paused)."""
return self.timer return self.timer and not self.paused
@property @property
def is_pick_expired(self) -> bool: def is_pick_expired(self) -> bool:
"""Check if the current pick deadline has passed.""" """Check if the current pick deadline has passed."""
if not self.pick_deadline: if not self.pick_deadline:
return False return False
return datetime.now() > self.pick_deadline return datetime.now() > self.pick_deadline
@property
def can_make_picks(self) -> bool:
"""Check if picks are allowed (not paused)."""
return not self.paused
def __str__(self): def __str__(self):
status = "Active" if self.is_draft_active else "Inactive" if self.paused:
status = "PAUSED"
elif self.timer:
status = "Active"
else:
status = "Inactive"
return f"Draft {status}: Pick {self.currentpick} ({self.pick_minutes}min timer)" return f"Draft {status}: Pick {self.currentpick} ({self.pick_minutes}min timer)"

View File

@ -3,7 +3,7 @@ Draft preference list model
Represents team draft board rankings and preferences. Represents team draft board rankings and preferences.
""" """
from typing import Optional from typing import Optional, Dict, Any
from pydantic import Field from pydantic import Field
from models.base import SBABaseModel from models.base import SBABaseModel
@ -21,6 +21,32 @@ class DraftList(SBABaseModel):
team: Team = Field(..., description="Team object") team: Team = Field(..., description="Team object")
player: Player = Field(..., description="Player object") player: Player = Field(..., description="Player object")
@classmethod
def from_api_data(cls, data: Dict[str, Any]) -> 'DraftList':
"""
Create DraftList instance from API data, ensuring nested objects are properly handled.
The API returns nested team and player objects. We need to ensure Player.from_api_data()
is called so that player.team_id is properly extracted from the nested team object.
Without this, Pydantic's default construction doesn't call from_api_data() on nested
objects, leaving player.team_id as None.
"""
if not data:
raise ValueError("Cannot create DraftList from empty data")
# Make a copy to avoid modifying original
draft_list_data = data.copy()
# Handle nested team object
if 'team' in draft_list_data and isinstance(draft_list_data['team'], dict):
draft_list_data['team'] = Team.from_api_data(draft_list_data['team'])
# Handle nested player object - CRITICAL for team_id extraction
if 'player' in draft_list_data and isinstance(draft_list_data['player'], dict):
draft_list_data['player'] = Player.from_api_data(draft_list_data['player'])
return cls(**draft_list_data)
@property @property
def team_id(self) -> int: def team_id(self) -> int:
"""Extract team ID from nested team object.""" """Extract team ID from nested team object."""

View File

@ -2,9 +2,15 @@
Draft pick model Draft pick model
Represents individual draft picks with team and player relationships. Represents individual draft picks with team and player relationships.
API FIELD MAPPING:
The API returns fields without _id suffix (origowner, owner, player).
When the API short_output=false, these fields contain full Team/Player objects.
When short_output=true (or default), they contain integer IDs.
We use Pydantic aliases to handle both cases.
""" """
from typing import Optional from typing import Optional, Any, Dict, Union
from pydantic import Field from pydantic import Field, field_validator, model_validator
from models.base import SBABaseModel from models.base import SBABaseModel
from models.team import Team from models.team import Team
@ -13,21 +19,79 @@ from models.player import Player
class DraftPick(SBABaseModel): class DraftPick(SBABaseModel):
"""Draft pick model representing a single draft selection.""" """Draft pick model representing a single draft selection."""
season: int = Field(..., description="Draft season") season: int = Field(..., description="Draft season")
overall: int = Field(..., description="Overall pick number") overall: int = Field(..., description="Overall pick number")
round: int = Field(..., description="Draft round") round: int = Field(..., description="Draft round")
# Team relationships - IDs are required, objects are optional # Team relationships - IDs extracted from API response
# API returns "origowner" which can be int or Team object
origowner_id: int = Field(..., description="Original owning team ID") origowner_id: int = Field(..., description="Original owning team ID")
origowner: Optional[Team] = Field(None, description="Original owning team (populated when needed)") origowner: Optional[Team] = Field(None, description="Original owning team (populated when needed)")
# API returns "owner" which can be int or Team object
owner_id: Optional[int] = Field(None, description="Current owning team ID") owner_id: Optional[int] = Field(None, description="Current owning team ID")
owner: Optional[Team] = Field(None, description="Current owning team (populated when needed)") owner: Optional[Team] = Field(None, description="Current owning team (populated when needed)")
# Player selection # Player selection - API returns "player" which can be int or Player object
player_id: Optional[int] = Field(None, description="Selected player ID") player_id: Optional[int] = Field(None, description="Selected player ID")
player: Optional[Player] = Field(None, description="Selected player (populated when needed)") player: Optional[Player] = Field(None, description="Selected player (populated when needed)")
@classmethod
def from_api_data(cls, data: Dict[str, Any]) -> 'DraftPick':
"""
Create DraftPick from API response data.
Handles API field mapping:
- API returns 'origowner', 'owner', 'player' (without _id suffix)
- These can be integer IDs or full objects depending on short_output setting
"""
if not data:
raise ValueError("Cannot create DraftPick from empty data")
# Make a copy to avoid modifying the original
parsed = dict(data)
# Handle origowner: can be int ID or Team object
if 'origowner' in parsed:
origowner = parsed.pop('origowner')
if isinstance(origowner, dict):
# Full Team object from API
parsed['origowner'] = Team.from_api_data(origowner)
parsed['origowner_id'] = origowner.get('id', origowner)
elif isinstance(origowner, int):
# Just the ID
parsed['origowner_id'] = origowner
elif origowner is not None:
parsed['origowner_id'] = int(origowner)
# Handle owner: can be int ID or Team object
if 'owner' in parsed:
owner = parsed.pop('owner')
if isinstance(owner, dict):
# Full Team object from API
parsed['owner'] = Team.from_api_data(owner)
parsed['owner_id'] = owner.get('id', owner)
elif isinstance(owner, int):
# Just the ID
parsed['owner_id'] = owner
elif owner is not None:
parsed['owner_id'] = int(owner)
# Handle player: can be int ID or Player object (or None)
if 'player' in parsed:
player = parsed.pop('player')
if isinstance(player, dict):
# Full Player object from API
parsed['player'] = Player.from_api_data(player)
parsed['player_id'] = player.get('id', player)
elif isinstance(player, int):
# Just the ID
parsed['player_id'] = player
elif player is not None:
parsed['player_id'] = int(player)
return cls(**parsed)
@property @property
def is_traded(self) -> bool: def is_traded(self) -> bool:

View File

@ -47,6 +47,7 @@ class Team(SBABaseModel):
thumbnail: Optional[str] = Field(None, description="Team thumbnail URL") thumbnail: Optional[str] = Field(None, description="Team thumbnail URL")
color: Optional[str] = Field(None, description="Primary team color") color: Optional[str] = Field(None, description="Primary team color")
dice_color: Optional[str] = Field(None, description="Dice rolling color") dice_color: Optional[str] = Field(None, description="Dice rolling color")
salary_cap: Optional[float] = Field(None, description="Team-specific salary cap (None uses default)")
@classmethod @classmethod
def from_api_data(cls, data: dict) -> 'Team': def from_api_data(cls, data: dict) -> 'Team':

View File

@ -377,6 +377,65 @@ class SheetsService:
async def read_box_score(scorecard: pygsheets.Spreadsheet) -> Dict[str, List[int]] async def read_box_score(scorecard: pygsheets.Spreadsheet) -> Dict[str, List[int]]
``` ```
#### DraftSheetService Key Methods (NEW - December 2025)
```python
class DraftSheetService(SheetsService):
"""
Service for writing draft picks to Google Sheets.
Extends SheetsService to reuse authentication and async patterns.
"""
async def write_pick(
season: int,
overall: int,
orig_owner_abbrev: str,
owner_abbrev: str,
player_name: str,
swar: float
) -> bool
"""Write a single pick to the draft sheet. Returns True on success."""
async def write_picks_batch(
season: int,
picks: List[Tuple[int, str, str, str, float]]
) -> Tuple[int, int]
"""Batch write picks for resync operations. Returns (success_count, failure_count)."""
async def clear_picks_range(
season: int,
start_overall: int = 1,
end_overall: int = 512
) -> bool
"""Clear a range of picks from the sheet before resync."""
def get_sheet_url(season: int) -> Optional[str]
"""Get the draft sheet URL for display in embeds."""
```
**Integration Points:**
- `commands/draft/picks.py` - Writes pick to sheet after successful draft selection
- `tasks/draft_monitor.py` - Writes pick to sheet after auto-draft
- `commands/draft/admin.py` - `/draft-admin resync-sheet` for bulk recovery
**Fire-and-Forget Pattern:**
Draft sheet writes are non-critical (database is source of truth). On failure:
1. Log the error
2. Notify ping channel with warning message
3. Suggest `/draft-admin resync-sheet` for recovery
4. Never block the draft pick itself
**Configuration:**
```python
# config.py settings
draft_sheet_enabled: bool = True # Feature flag
draft_sheet_worksheet: str = "Ordered List" # Worksheet name
draft_sheet_start_column: str = "D" # Starting column
# Season-specific sheet IDs via environment variables
# DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc.
config.get_draft_sheet_key(season) # Returns sheet ID or None
config.get_draft_sheet_url(season) # Returns full URL or None
```
**Transaction Rollback Pattern:** **Transaction Rollback Pattern:**
The game submission services implement a 3-state transaction rollback pattern: The game submission services implement a 3-state transaction rollback pattern:
1. **PLAYS_POSTED**: Plays submitted → Rollback: Delete plays 1. **PLAYS_POSTED**: Plays submitted → Rollback: Delete plays

View File

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

View File

@ -33,6 +33,33 @@ class DraftPickService(BaseService[DraftPick]):
super().__init__(DraftPick, 'draftpicks') super().__init__(DraftPick, 'draftpicks')
logger.debug("DraftPickService initialized") logger.debug("DraftPickService initialized")
def _extract_items_and_count_from_response(self, data):
"""
Override to handle API quirk: GET returns 'picks' instead of 'draftpicks'.
Args:
data: API response data
Returns:
Tuple of (items list, total count)
"""
if isinstance(data, list):
return data, len(data)
if not isinstance(data, dict):
logger.warning(f"Unexpected response format: {type(data)}")
return [], 0
# Get count
count = data.get('count', 0)
# API returns items under 'picks' key (not 'draftpicks')
if 'picks' in data and isinstance(data['picks'], list):
return data['picks'], count or len(data['picks'])
# Fallback to standard extraction
return super()._extract_items_and_count_from_response(data)
async def get_pick(self, season: int, overall: int) -> Optional[DraftPick]: async def get_pick(self, season: int, overall: int) -> Optional[DraftPick]:
""" """
Get specific pick by season and overall number. Get specific pick by season and overall number.
@ -181,6 +208,52 @@ class DraftPickService(BaseService[DraftPick]):
logger.error(f"Error getting available picks: {e}") logger.error(f"Error getting available picks: {e}")
return [] return []
async def get_skipped_picks_for_team(
self,
season: int,
team_id: int,
current_overall: int
) -> List[DraftPick]:
"""
Get skipped picks for a team (picks before current that have no player selected).
A "skipped" pick is one where:
- The pick overall is LESS than the current overall (it has passed)
- The pick has no player_id assigned
- The pick's current owner is the specified team
NOT cached - picks change during draft.
Args:
season: Draft season
team_id: Team ID to check for skipped picks
current_overall: Current overall pick number in the draft
Returns:
List of skipped DraftPick instances owned by team, ordered by overall (ascending)
"""
try:
# Get all picks owned by this team that are before the current pick
# and have not been selected
params = [
('season', str(season)),
('owner_team_id', str(team_id)),
('overall_end', str(current_overall - 1)), # Before current pick
('player_taken', 'false'), # No player selected
('sort', 'order-asc') # Earliest skipped pick first
]
picks = await self.get_all_items(params=params)
logger.debug(
f"Found {len(picks)} skipped picks for team {team_id} "
f"before pick #{current_overall}"
)
return picks
except Exception as e:
logger.error(f"Error getting skipped picks for team {team_id}: {e}")
return []
async def get_recent_picks( async def get_recent_picks(
self, self,
season: int, season: int,
@ -252,6 +325,35 @@ class DraftPickService(BaseService[DraftPick]):
logger.error(f"Error getting upcoming picks: {e}") logger.error(f"Error getting upcoming picks: {e}")
return [] return []
async def get_picks_with_players(self, season: int) -> List[DraftPick]:
"""
Get all picks for a season with player data included.
Used for bulk operations like resync-sheet. Returns all picks
for the season regardless of whether they have been selected.
NOT cached - picks change during draft.
Args:
season: Draft season
Returns:
List of all DraftPick instances for the season
"""
try:
params = [
('season', str(season)),
('sort', 'order-asc')
]
picks = await self.get_all_items(params=params)
logger.debug(f"Found {len(picks)} picks for season {season}")
return picks
except Exception as e:
logger.error(f"Error getting all picks for season {season}: {e}")
return []
async def update_pick_selection( async def update_pick_selection(
self, self,
pick_id: int, pick_id: int,

View File

@ -338,6 +338,82 @@ class DraftService(BaseService[DraftData]):
logger.error(f"Error resetting draft deadline: {e}") logger.error(f"Error resetting draft deadline: {e}")
return None return None
async def pause_draft(self, draft_id: int) -> Optional[DraftData]:
"""
Pause the draft, blocking all picks (manual and auto) and stopping the timer.
When paused:
- /draft command will reject picks with "Draft is paused" message
- Auto-draft monitor will skip auto-drafting
- Timer is stopped (deadline set far in future)
- On resume, timer will restart with fresh deadline
Args:
draft_id: DraftData database ID
Returns:
Updated DraftData with paused=True and timer stopped
"""
try:
# Pause the draft AND stop the timer
# Set deadline far in future so it doesn't expire while paused
updates = {
'paused': True,
'timer': False,
'pick_deadline': datetime.now() + timedelta(days=690)
}
updated = await self.update_draft_data(draft_id, updates)
if updated:
logger.info("Draft paused - all picks blocked and timer stopped")
else:
logger.error("Failed to pause draft")
return updated
except Exception as e:
logger.error(f"Error pausing draft: {e}")
return None
async def resume_draft(self, draft_id: int) -> Optional[DraftData]:
"""
Resume the draft, allowing picks again and restarting the timer.
When resumed:
- Timer is restarted with fresh deadline based on pick_minutes
- All picks (manual and auto) are allowed again
Args:
draft_id: DraftData database ID
Returns:
Updated DraftData with paused=False and timer restarted
"""
try:
# Get current draft data to get pick_minutes setting
current_data = await self.get_draft_data()
pick_minutes = current_data.pick_minutes if current_data else 2
# Resume the draft AND restart the timer with fresh deadline
new_deadline = datetime.now() + timedelta(minutes=pick_minutes)
updates = {
'paused': False,
'timer': True,
'pick_deadline': new_deadline
}
updated = await self.update_draft_data(draft_id, updates)
if updated:
logger.info(f"Draft resumed - timer restarted with {pick_minutes}min deadline")
else:
logger.error("Failed to resume draft")
return updated
except Exception as e:
logger.error(f"Error resuming draft: {e}")
return None
# Global service instance # Global service instance
draft_service = DraftService() draft_service = DraftService()

View File

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

View File

@ -245,15 +245,35 @@ class PlayerService(BaseService[Player]):
async def is_free_agent(self, player: Player) -> bool: async def is_free_agent(self, player: Player) -> bool:
""" """
Check if a player is a free agent. Check if a player is a free agent.
Args: Args:
player: Player instance to check player: Player instance to check
Returns: Returns:
True if player is a free agent True if player is a free agent
""" """
return player.team_id == get_config().free_agent_team_id return player.team_id == get_config().free_agent_team_id
async def get_top_free_agents(self, season: int, limit: int = 5) -> List[Player]:
"""
Get top free agents sorted by sWAR (wara) descending.
Args:
season: Season number (required)
limit: Maximum number of players to return (default 5)
Returns:
List of top free agent players sorted by sWAR
"""
try:
free_agents = await self.get_free_agents(season)
# Sort by wara descending and take top N
sorted_fa = sorted(free_agents, key=lambda p: p.wara if p.wara else 0.0, reverse=True)
return sorted_fa[:limit]
except Exception as e:
logger.error(f"Failed to get top free agents: {e}")
return []
async def get_players_by_position(self, position: str, season: int) -> List[Player]: async def get_players_by_position(self, position: str, season: int) -> List[Player]:
""" """
Get players by position. Get players by position.

View File

@ -231,16 +231,27 @@ When voice channels are cleaned up (deleted after being empty):
- Prevents duplicate error messages - Prevents duplicate error messages
- Continues operation despite individual scorecard failures - Continues operation despite individual scorecard failures
### Draft Monitor (`draft_monitor.py`) (NEW - October 2025) ### Draft Monitor (`draft_monitor.py`) (Updated December 2025)
**Purpose:** Automated draft timer monitoring, warnings, and auto-draft execution **Purpose:** Automated draft timer monitoring, warnings, and auto-draft execution
**Schedule:** Every 15 seconds (only when draft timer is active) **Schedule:** Smart polling intervals based on time remaining:
- **30 seconds** when >60s remaining on pick
- **15 seconds** when 30-60s remaining
- **5 seconds** when <30s remaining
**Operations:** **Operations:**
- **Timer Monitoring:** - **Timer Monitoring:**
- Checks draft state every 15 seconds - Auto-starts when timer enabled via `/draft-admin timer`
- Auto-starts when `/draft-admin set-pick` used with active timer
- Self-terminates when `draft_data.timer = False` - Self-terminates when `draft_data.timer = False`
- Restarts when timer re-enabled via `/draft-admin` - Uses `_ensure_monitor_running()` helper for consistent management
- **On-Clock Announcements:**
- Posts announcement embed when pick advances
- Shows team name, pick info, and deadline
- Displays team sWAR and cap space
- Lists last 5 picks
- Shows top 5 roster players by sWAR
- **Warning System:** - **Warning System:**
- Sends 60-second warning to ping channel - Sends 60-second warning to ping channel
@ -255,6 +266,8 @@ When voice channels are cleaned up (deleted after being empty):
- Advances to next pick after auto-draft - Advances to next pick after auto-draft
#### Key Features #### Key Features
- **Auto-Start:** Starts automatically when timer enabled or pick set
- **Smart Polling:** Adjusts check frequency based on urgency
- **Self-Terminating:** Stops automatically when timer disabled (resource efficient) - **Self-Terminating:** Stops automatically when timer disabled (resource efficient)
- **Global Lock Integration:** Acquires same lock as `/draft` command - **Global Lock Integration:** Acquires same lock as `/draft` command
- **Crash Recovery:** Respects 30-second stale lock timeout - **Crash Recovery:** Respects 30-second stale lock timeout
@ -301,8 +314,30 @@ async with draft_picks_cog.pick_lock:
- Validate cap space - Validate cap space
- Attempt to draft player - Attempt to draft player
- Break on success - Break on success
5. Advance to next pick 5. Write pick to Google Sheets (fire-and-forget)
6. Release lock 6. Advance to next pick
7. Release lock
#### Google Sheets Integration
The monitor writes picks to the draft sheet after successful auto-draft:
- Uses **fire-and-forget** pattern (non-blocking)
- Failures logged but don't block draft
- Same service as manual `/draft` command
- Sheet write occurs before pick advancement
```python
# After successful auto-draft execution
sheet_success = await draft_sheet_service.write_pick(
season=config.sba_season,
overall=pick.overall,
orig_owner_abbrev=original_owner.abbrev,
owner_abbrev=team.abbrev,
player_name=player.name,
swar=player.wara
)
if not sheet_success:
logger.warning(f"Sheet write failed for auto-draft pick #{pick.overall}")
```
#### Channel Requirements #### Channel Requirements
- **ping_channel** - Where warnings and auto-draft announcements post - **ping_channel** - Where warnings and auto-draft announcements post

View File

@ -14,10 +14,14 @@ 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 utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
from utils.helpers import get_team_salary_cap
from views.embeds import EmbedTemplate, EmbedColors from views.embeds import EmbedTemplate, EmbedColors
from views.draft_views import create_on_clock_announcement_embed
from config import get_config from config import get_config
@ -50,10 +54,35 @@ class DraftMonitorTask:
"""Stop the task when cog is unloaded.""" """Stop the task when cog is unloaded."""
self.monitor_loop.cancel() self.monitor_loop.cancel()
@tasks.loop(seconds=15) def _get_poll_interval(self, time_remaining: float) -> int:
"""
Get the appropriate polling interval based on time remaining.
Args:
time_remaining: Seconds until deadline
Returns:
Poll interval in seconds:
- 30s when > 60s remaining
- 15s when 30-60s remaining
- 5s when < 30s remaining
"""
if time_remaining > 60:
return 30
elif time_remaining > 30:
return 15
else:
return 5
@tasks.loop(seconds=30)
async def monitor_loop(self): async def monitor_loop(self):
""" """
Main monitoring loop - checks draft state every 15 seconds. Main monitoring loop - checks draft state with dynamic intervals.
Polling frequency increases as deadline approaches:
- Every 30s when > 60s remaining
- Every 15s when 30-60s remaining
- Every 5s when < 30s remaining
Self-terminates when draft timer is disabled. Self-terminates when draft timer is disabled.
""" """
@ -71,6 +100,11 @@ class DraftMonitorTask:
self.monitor_loop.cancel() self.monitor_loop.cancel()
return return
# CRITICAL: Skip auto-draft if paused (but keep monitoring)
if draft_data.paused:
self.logger.debug("Draft is paused - skipping auto-draft actions")
return
# Check if we need to take action # Check if we need to take action
now = datetime.now() now = datetime.now()
deadline = draft_data.pick_deadline deadline = draft_data.pick_deadline
@ -82,6 +116,12 @@ class DraftMonitorTask:
# Calculate time remaining # Calculate time remaining
time_remaining = (deadline - now).total_seconds() time_remaining = (deadline - now).total_seconds()
# Adjust polling interval based on time remaining
new_interval = self._get_poll_interval(time_remaining)
if self.monitor_loop.seconds != new_interval:
self.monitor_loop.change_interval(seconds=new_interval)
self.logger.debug(f"Adjusted poll interval to {new_interval}s (time remaining: {time_remaining:.0f}s)")
if time_remaining <= 0: if time_remaining <= 0:
# Timer expired - auto-draft # Timer expired - auto-draft
await self._handle_expired_timer(draft_data) await self._handle_expired_timer(draft_data)
@ -180,25 +220,43 @@ class DraftMonitorTask:
) )
# Advance to next pick # Advance to next pick
await draft_service.advance_pick(draft_data.id, draft_data.currentpick) await draft_service.advance_pick(draft_data.id, draft_data.currentpick)
# Post on-clock announcement for next team
await self._post_on_clock_announcement(ping_channel, draft_data)
# Reset warning flags
self.warning_60s_sent = False
self.warning_30s_sent = False
return return
# Try each player in order # Try each player in order
for entry in draft_list: for entry in draft_list:
if not entry.player: if not entry.player:
self.logger.debug(f"Draft list entry has no player, skipping")
continue continue
player = entry.player player = entry.player
# Debug: Log player team_id for troubleshooting
self.logger.debug(
f"Checking player {player.name}: team_id={player.team_id}, "
f"FA team_id={config.free_agent_team_id}, "
f"team.id={player.team.id if player.team else 'None'}"
)
# Check if player is still available # Check if player is still available
if player.team_id != config.free_agent_team_id: if player.team_id != config.free_agent_team_id:
self.logger.debug(f"Player {player.name} no longer available, skipping") self.logger.debug(
f"Player {player.name} no longer available "
f"(team_id={player.team_id} != FA={config.free_agent_team_id}), skipping"
)
continue continue
# Attempt to draft this player # Attempt to draft this player
success = await self._attempt_draft_player( success = await self._attempt_draft_player(
current_pick, current_pick,
player, player,
ping_channel ping_channel,
draft_data,
guild
) )
if success: if success:
@ -207,6 +265,8 @@ class DraftMonitorTask:
) )
# Advance to next pick # Advance to next pick
await draft_service.advance_pick(draft_data.id, draft_data.currentpick) await draft_service.advance_pick(draft_data.id, draft_data.currentpick)
# Post on-clock announcement for next team
await self._post_on_clock_announcement(ping_channel, draft_data)
# Reset warning flags # Reset warning flags
self.warning_60s_sent = False self.warning_60s_sent = False
self.warning_30s_sent = False self.warning_30s_sent = False
@ -219,6 +279,11 @@ class DraftMonitorTask:
) )
# Advance to next pick anyway # Advance to next pick anyway
await draft_service.advance_pick(draft_data.id, draft_data.currentpick) await draft_service.advance_pick(draft_data.id, draft_data.currentpick)
# Post on-clock announcement for next team
await self._post_on_clock_announcement(ping_channel, draft_data)
# Reset warning flags
self.warning_60s_sent = False
self.warning_30s_sent = False
except Exception as e: except Exception as e:
self.logger.error("Error auto-drafting player", error=e) self.logger.error("Error auto-drafting player", error=e)
@ -227,7 +292,9 @@ class DraftMonitorTask:
self, self,
draft_pick, draft_pick,
player, player,
ping_channel ping_channel,
draft_data,
guild
) -> bool: ) -> bool:
""" """
Attempt to draft a specific player. Attempt to draft a specific player.
@ -236,6 +303,8 @@ class DraftMonitorTask:
draft_pick: DraftPick to update draft_pick: DraftPick to update
player: Player to draft player: Player to draft
ping_channel: Discord channel for announcements ping_channel: Discord channel for announcements
draft_data: Draft configuration (for result_channel)
guild: Discord guild (for channel lookup)
Returns: Returns:
True if draft succeeded True if draft succeeded
@ -252,7 +321,7 @@ class DraftMonitorTask:
return False return False
# Validate cap space # Validate cap space
is_valid, projected_total = await validate_cap_space(roster, player.wara) is_valid, projected_total, cap_limit = await validate_cap_space(roster, player.wara)
if not is_valid: if not is_valid:
self.logger.debug( self.logger.debug(
@ -282,18 +351,110 @@ 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
# Post to channel # Write pick to Google Sheets (fire-and-forget)
await self._write_pick_to_sheets(draft_pick, player, ping_channel)
# Post to ping 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}** "
f"(Pick #{draft_pick.overall})" f"(Pick #{draft_pick.overall})"
) )
# Post draft card to result channel (same as regular /draft picks)
if draft_data.result_channel:
result_channel = guild.get_channel(draft_data.result_channel)
if result_channel:
from views.draft_views import create_player_draft_card
draft_card = await create_player_draft_card(player, draft_pick)
draft_card.set_footer(text="🤖 Auto-drafted from draft list")
await result_channel.send(embed=draft_card)
else:
self.logger.warning(f"Could not find result channel {draft_data.result_channel}")
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Error attempting to draft {player.name}", error=e) self.logger.error(f"Error attempting to draft {player.name}", error=e)
return False return False
async def _post_on_clock_announcement(self, ping_channel, draft_data) -> None:
"""
Post the on-clock announcement embed for the next team.
Called after advance_pick() to announce who is now on the clock.
Args:
ping_channel: Discord channel to post in
draft_data: Current draft configuration (will be refreshed)
"""
try:
config = get_config()
# Refresh draft data to get updated currentpick and deadline
updated_draft_data = await draft_service.get_draft_data()
if not updated_draft_data:
self.logger.error("Could not refresh draft data for announcement")
return
# Get the new current pick
next_pick = await draft_pick_service.get_pick(
config.sba_season,
updated_draft_data.currentpick
)
if not next_pick or not next_pick.owner:
self.logger.error(f"Could not get pick #{updated_draft_data.currentpick} for announcement")
return
# Get recent picks (last 5 completed)
recent_picks = await draft_pick_service.get_recent_picks(
config.sba_season,
updated_draft_data.currentpick - 1, # Start from previous pick
limit=5
)
# Get team roster for sWAR calculation
team_roster = await roster_service.get_team_roster(next_pick.owner.id, "current")
roster_swar = team_roster.total_wara if team_roster else 0.0
cap_limit = get_team_salary_cap(next_pick.owner)
# Get top 5 most expensive players on team roster
top_roster_players = []
if team_roster:
all_players = team_roster.all_players
sorted_players = sorted(all_players, key=lambda p: p.wara if p.wara else 0.0, reverse=True)
top_roster_players = sorted_players[:5]
# Get sheet URL
sheet_url = config.get_draft_sheet_url(config.sba_season)
# Create and send the embed
embed = await create_on_clock_announcement_embed(
current_pick=next_pick,
draft_data=updated_draft_data,
recent_picks=recent_picks if recent_picks else [],
roster_swar=roster_swar,
cap_limit=cap_limit,
top_roster_players=top_roster_players,
sheet_url=sheet_url
)
# Mention the team's GM if available
gm_mention = ""
if next_pick.owner.gmid:
gm_mention = f"<@{next_pick.owner.gmid}> "
await ping_channel.send(content=gm_mention, embed=embed)
self.logger.info(f"Posted on-clock announcement for pick #{updated_draft_data.currentpick}")
# Reset poll interval to 30s for new pick
if self.monitor_loop.seconds != 30:
self.monitor_loop.change_interval(seconds=30)
self.logger.debug("Reset poll interval to 30s for new pick")
except Exception as e:
self.logger.error("Error posting on-clock announcement", error=e)
async def _send_warnings_if_needed(self, draft_data, time_remaining: float): async def _send_warnings_if_needed(self, draft_data, time_remaining: float):
""" """
Send warnings at 60s and 30s remaining. Send warnings at 60s and 30s remaining.
@ -350,6 +511,65 @@ class DraftMonitorTask:
except Exception as e: except Exception as e:
self.logger.error("Error sending warnings", error=e) self.logger.error("Error sending warnings", error=e)
async def _write_pick_to_sheets(self, draft_pick, player, ping_channel) -> None:
"""
Write pick to Google Sheets (fire-and-forget with notification on failure).
Args:
draft_pick: The draft pick being used
player: Player being drafted
ping_channel: Discord channel for failure notification
"""
config = get_config()
try:
draft_sheet_service = get_draft_sheet_service()
success = await draft_sheet_service.write_pick(
season=config.sba_season,
overall=draft_pick.overall,
orig_owner_abbrev=draft_pick.origowner.abbrev if draft_pick.origowner else draft_pick.owner.abbrev,
owner_abbrev=draft_pick.owner.abbrev,
player_name=player.name,
swar=player.wara
)
if not success:
# Write failed - notify in ping channel
await self._notify_sheet_failure(
ping_channel=ping_channel,
pick_overall=draft_pick.overall,
player_name=player.name
)
except Exception as e:
self.logger.warning(f"Failed to write pick to sheets: {e}")
await self._notify_sheet_failure(
ping_channel=ping_channel,
pick_overall=draft_pick.overall,
player_name=player.name
)
async def _notify_sheet_failure(self, ping_channel, pick_overall: int, player_name: str) -> None:
"""
Post notification to ping channel when sheet write fails.
Args:
ping_channel: Discord channel to notify
pick_overall: Pick number that failed
player_name: Player name
"""
if not ping_channel:
return
try:
await ping_channel.send(
f"⚠️ **Sheet Sync Failed** - Pick #{pick_overall} ({player_name}) "
f"was not written to the draft sheet. "
f"Use `/draft-admin resync-sheet` to manually sync."
)
except Exception as e:
self.logger.error(f"Failed to send sheet failure notification: {e}")
# Task factory function # Task factory function
def setup_draft_monitor(bot: commands.Bot) -> DraftMonitorTask: def setup_draft_monitor(bot: commands.Bot) -> DraftMonitorTask:

View File

@ -474,6 +474,68 @@ class TestDraftListModel:
assert top_pick.is_top_ranked is True assert top_pick.is_top_ranked is True
assert lower_pick.is_top_ranked is False assert lower_pick.is_top_ranked is False
def test_draft_list_from_api_data_extracts_player_team_id(self):
"""
Test that DraftList.from_api_data() properly extracts player.team_id from nested team object.
This is critical for auto-draft functionality. The API returns player data with a nested
team object (not a flat team_id). Without the custom from_api_data(), Pydantic's default
construction doesn't call Player.from_api_data(), leaving player.team_id as None.
Bug fixed: Auto-draft was failing because player.team_id was None, causing all players
to be incorrectly marked as "not available" (None != 547 always True).
"""
# Simulate API response format - nested objects, NOT flat IDs
api_response = {
'id': 303,
'season': 13,
'rank': 1,
'team': {
'id': 548,
'abbrev': 'WV',
'sname': 'Black Bears',
'lname': 'West Virginia Black Bears',
'season': 13
},
'player': {
'id': 12843,
'name': 'George Springer',
'wara': 0.31,
'image': 'https://example.com/springer.png',
'season': 13,
'pos_1': 'CF',
# Note: NO flat team_id here - it's nested in 'team' below
'team': {
'id': 547, # Free Agent team
'abbrev': 'FA',
'sname': 'Free Agents',
'lname': 'Free Agents',
'season': 13
}
}
}
# Create DraftList using from_api_data (what BaseService calls)
draft_entry = DraftList.from_api_data(api_response)
# Verify nested objects are created
assert draft_entry.team is not None
assert draft_entry.player is not None
# CRITICAL: player.team_id must be extracted from nested team object
assert draft_entry.player.team_id == 547, \
f"player.team_id should be 547 (FA), got {draft_entry.player.team_id}"
# Verify the nested team object is also populated
assert draft_entry.player.team is not None
assert draft_entry.player.team.id == 547
assert draft_entry.player.team.abbrev == 'FA'
# Verify DraftList's own team data
assert draft_entry.team.id == 548
assert draft_entry.team.abbrev == 'WV'
assert draft_entry.team_id == 548 # Property from nested team
class TestModelCoverageExtras: class TestModelCoverageExtras:
"""Additional model coverage tests.""" """Additional model coverage tests."""

View File

@ -39,13 +39,14 @@ def create_draft_data(**overrides) -> dict:
""" """
Create complete draft data matching API response format. Create complete draft data matching API response format.
API returns: id, currentpick, timer, pick_deadline, result_channel, API returns: id, currentpick, timer, paused, pick_deadline, result_channel,
ping_channel, pick_minutes ping_channel, pick_minutes
""" """
base_data = { base_data = {
'id': 1, 'id': 1,
'currentpick': 25, 'currentpick': 25,
'timer': True, 'timer': True,
'paused': False, # New field for draft pause feature
'pick_deadline': (datetime.now() + timedelta(minutes=10)).isoformat(), 'pick_deadline': (datetime.now() + timedelta(minutes=10)).isoformat(),
'result_channel': '123456789012345678', # API returns as string 'result_channel': '123456789012345678', # API returns as string
'ping_channel': '987654321098765432', # API returns as string 'ping_channel': '987654321098765432', # API returns as string
@ -450,6 +451,160 @@ class TestDraftService:
assert patch_call[0][1]['ping_channel'] == 111111111111111111 assert patch_call[0][1]['ping_channel'] == 111111111111111111
assert patch_call[0][1]['result_channel'] == 222222222222222222 assert patch_call[0][1]['result_channel'] == 222222222222222222
# -------------------------------------------------------------------------
# pause_draft() tests
# -------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pause_draft_success(self, service, mock_client):
"""
Test successfully pausing the draft.
Verifies:
- PATCH is called with paused=True, timer=False, and far-future deadline
- Updated draft data with paused=True is returned
- Timer is stopped when draft is paused (prevents deadline expiry during pause)
"""
updated_data = create_draft_data(paused=True, timer=False)
mock_client.patch.return_value = updated_data
result = await service.pause_draft(draft_id=1)
assert result is not None
assert result.paused is True
assert result.timer is False
# Verify PATCH was called with all pause-related updates
patch_call = mock_client.patch.call_args
patch_data = patch_call[0][1]
assert patch_data['paused'] is True
assert patch_data['timer'] is False
assert 'pick_deadline' in patch_data # Far-future deadline set
@pytest.mark.asyncio
async def test_pause_draft_failure(self, service, mock_client):
"""
Test handling of failed pause operation.
Verifies service returns None when PATCH fails.
"""
mock_client.patch.return_value = None
result = await service.pause_draft(draft_id=1)
assert result is None
@pytest.mark.asyncio
async def test_pause_draft_api_error(self, service, mock_client):
"""
Test error handling when pause API call fails.
Verifies service returns None on exception rather than crashing.
"""
mock_client.patch.side_effect = Exception("API unavailable")
result = await service.pause_draft(draft_id=1)
assert result is None
# -------------------------------------------------------------------------
# resume_draft() tests
# -------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resume_draft_success(self, service, mock_client):
"""
Test successfully resuming the draft.
Verifies:
- Current draft data is fetched to get pick_minutes
- PATCH is called with paused=False, timer=True, and fresh deadline
- Timer is restarted when draft is resumed
"""
# First call: get_draft_data to fetch pick_minutes
current_data = create_draft_data(paused=True, timer=False, pick_minutes=5)
mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]}
# Second call: patch returns updated data
updated_data = create_draft_data(paused=False, timer=True, pick_minutes=5)
mock_client.patch.return_value = updated_data
result = await service.resume_draft(draft_id=1)
assert result is not None
assert result.paused is False
assert result.timer is True
# Verify PATCH was called with all resume-related updates
patch_call = mock_client.patch.call_args
patch_data = patch_call[0][1]
assert patch_data['paused'] is False
assert patch_data['timer'] is True
assert 'pick_deadline' in patch_data # Fresh deadline set
@pytest.mark.asyncio
async def test_resume_draft_failure(self, service, mock_client):
"""
Test handling of failed resume operation.
Verifies service returns None when PATCH fails.
"""
# First call: get_draft_data succeeds
current_data = create_draft_data(paused=True, timer=False)
mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]}
# PATCH fails
mock_client.patch.return_value = None
result = await service.resume_draft(draft_id=1)
assert result is None
@pytest.mark.asyncio
async def test_resume_draft_api_error(self, service, mock_client):
"""
Test error handling when resume API call fails.
Verifies service returns None on exception rather than crashing.
"""
# First call: get_draft_data succeeds
current_data = create_draft_data(paused=True, timer=False)
mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]}
# PATCH fails with exception
mock_client.patch.side_effect = Exception("API unavailable")
result = await service.resume_draft(draft_id=1)
assert result is None
@pytest.mark.asyncio
async def test_pause_resume_roundtrip(self, service, mock_client):
"""
Test pausing and then resuming the draft.
Verifies the complete pause/resume workflow:
1. Pause stops the timer
2. Resume restarts the timer with fresh deadline
"""
# First pause - timer should be stopped
paused_data = create_draft_data(paused=True, timer=False)
mock_client.patch.return_value = paused_data
pause_result = await service.pause_draft(draft_id=1)
assert pause_result.paused is True
assert pause_result.timer is False
# Then resume - timer should be restarted
# resume_draft first fetches current data to get pick_minutes
mock_client.get.return_value = {'count': 1, 'draftdata': [paused_data]}
resumed_data = create_draft_data(paused=False, timer=True)
mock_client.patch.return_value = resumed_data
resume_result = await service.resume_draft(draft_id=1)
assert resume_result.paused is False
assert resume_result.timer is True
# ============================================================================= # =============================================================================
# DraftPickService Tests # DraftPickService Tests
@ -775,6 +930,89 @@ class TestDraftPickService:
assert patch_data['player_id'] is None assert patch_data['player_id'] is None
assert 'overall' in patch_data # Full model required assert 'overall' in patch_data # Full model required
@pytest.mark.asyncio
async def test_get_skipped_picks_for_team_success(self, service, mock_client):
"""
Test retrieving skipped picks for a team.
Skipped picks are picks before the current overall that have no player selected.
Returns picks ordered by overall (ascending) so earliest skipped pick is first.
"""
# Team 5 has two skipped picks (overall 10 and 15) before current pick 25
skipped_pick_1 = create_draft_pick_data(
pick_id=10, overall=10, round_num=1, player_id=None,
owner_team_id=5, include_nested=False
)
skipped_pick_2 = create_draft_pick_data(
pick_id=15, overall=15, round_num=1, player_id=None,
owner_team_id=5, include_nested=False
)
mock_client.get.return_value = {
'count': 2,
'picks': [skipped_pick_1, skipped_pick_2]
}
result = await service.get_skipped_picks_for_team(
season=12,
team_id=5,
current_overall=25
)
# Verify results
assert len(result) == 2
assert result[0].overall == 10 # Earliest skipped pick first
assert result[1].overall == 15
assert result[0].player_id is None
assert result[1].player_id is None
# Verify API call
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
params = call_args[1]['params']
# Should request picks before current (overall_end=24), owned by team, with no player
assert ('overall_end', '24') in params
assert ('owner_team_id', '5') in params
assert ('player_taken', 'false') in params
@pytest.mark.asyncio
async def test_get_skipped_picks_for_team_none_found(self, service, mock_client):
"""
Test when team has no skipped picks.
Returns empty list when all prior picks have been made.
"""
mock_client.get.return_value = {
'count': 0,
'picks': []
}
result = await service.get_skipped_picks_for_team(
season=12,
team_id=5,
current_overall=25
)
assert result == []
@pytest.mark.asyncio
async def test_get_skipped_picks_for_team_api_error(self, service, mock_client):
"""
Test graceful handling of API errors.
Returns empty list on error rather than raising exception.
"""
mock_client.get.side_effect = Exception("API Error")
result = await service.get_skipped_picks_for_team(
season=12,
team_id=5,
current_overall=25
)
# Should return empty list on error, not raise
assert result == []
# ============================================================================= # =============================================================================
# DraftListService Tests # DraftListService Tests
@ -1279,6 +1517,72 @@ class TestDraftDataModel:
assert active.is_draft_active is True assert active.is_draft_active is True
assert inactive.is_draft_active is False assert inactive.is_draft_active is False
def test_is_draft_active_when_paused(self):
"""
Test that is_draft_active returns False when draft is paused.
Even if timer is True, is_draft_active should be False when paused
because no picks should be processed.
"""
paused_with_timer = DraftData(
id=1, currentpick=1, timer=True, paused=True, pick_minutes=2
)
paused_no_timer = DraftData(
id=1, currentpick=1, timer=False, paused=True, pick_minutes=2
)
active_not_paused = DraftData(
id=1, currentpick=1, timer=True, paused=False, pick_minutes=2
)
assert paused_with_timer.is_draft_active is False
assert paused_no_timer.is_draft_active is False
assert active_not_paused.is_draft_active is True
def test_can_make_picks_property(self):
"""
Test can_make_picks property correctly reflects pause state.
can_make_picks should be True only when not paused,
regardless of timer state.
"""
# Not paused - can make picks
not_paused = DraftData(
id=1, currentpick=1, timer=True, paused=False, pick_minutes=2
)
assert not_paused.can_make_picks is True
# Paused - cannot make picks
paused = DraftData(
id=1, currentpick=1, timer=True, paused=True, pick_minutes=2
)
assert paused.can_make_picks is False
# Not paused, timer off - can still make picks (manual draft)
manual_draft = DraftData(
id=1, currentpick=1, timer=False, paused=False, pick_minutes=2
)
assert manual_draft.can_make_picks is True
def test_draft_data_str_shows_paused_status(self):
"""
Test that __str__ displays paused status when draft is paused.
Users should clearly see when the draft is paused.
"""
paused = DraftData(
id=1, currentpick=25, timer=True, paused=True, pick_minutes=2
)
active = DraftData(
id=1, currentpick=25, timer=True, paused=False, pick_minutes=2
)
inactive = DraftData(
id=1, currentpick=25, timer=False, paused=False, pick_minutes=2
)
assert "PAUSED" in str(paused)
assert "Active" in str(active)
assert "Inactive" in str(inactive)
def test_is_pick_expired_property(self): def test_is_pick_expired_property(self):
"""Test is_pick_expired property.""" """Test is_pick_expired property."""
# Expired deadline # Expired deadline

View File

@ -0,0 +1,349 @@
"""
Tests for DraftSheetService
Tests the Google Sheets integration for draft pick tracking.
Uses mocked pygsheets to avoid actual API calls.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from typing import Tuple, List
from services.draft_sheet_service import DraftSheetService, get_draft_sheet_service
class TestDraftSheetService:
"""
Test suite for DraftSheetService.
Tests write_pick(), write_picks_batch(), clear_picks_range(), and get_sheet_url().
All tests mock pygsheets to avoid actual Google Sheets API calls.
"""
@pytest.fixture
def mock_config(self):
"""
Create a mock config with draft sheet settings.
Provides:
- draft_sheet_enabled: True
- sba_season: 12
- draft_sheet_worksheet: "Ordered List"
- draft_sheet_start_column: "D"
- draft_total_picks: 512
"""
config = MagicMock()
config.draft_sheet_enabled = True
config.sba_season = 12
config.draft_sheet_worksheet = "Ordered List"
config.draft_sheet_start_column = "D"
config.draft_total_picks = 512
config.sheets_credentials_path = "/app/data/test-creds.json"
config.get_draft_sheet_key = MagicMock(return_value="test-sheet-key-123")
config.get_draft_sheet_url = MagicMock(
return_value="https://docs.google.com/spreadsheets/d/test-sheet-key-123"
)
return config
@pytest.fixture
def mock_pygsheets(self):
"""
Create mock pygsheets client, spreadsheet, and worksheet.
Provides:
- sheets_client: Mock pygsheets client
- spreadsheet: Mock spreadsheet
- worksheet: Mock worksheet with update_values method
"""
worksheet = MagicMock()
worksheet.update_values = MagicMock()
spreadsheet = MagicMock()
spreadsheet.worksheet_by_title = MagicMock(return_value=worksheet)
sheets_client = MagicMock()
sheets_client.open_by_key = MagicMock(return_value=spreadsheet)
return {
'client': sheets_client,
'spreadsheet': spreadsheet,
'worksheet': worksheet
}
@pytest.fixture
def service(self, mock_config, mock_pygsheets):
"""
Create DraftSheetService instance with mocked dependencies.
The service is set up with:
- Mocked config
- Mocked pygsheets client (via _get_client override)
"""
with patch('services.draft_sheet_service.get_config', return_value=mock_config):
service = DraftSheetService()
service._config = mock_config
service._sheets_client = mock_pygsheets['client']
return service
# ==================== write_pick() Tests ====================
@pytest.mark.asyncio
async def test_write_pick_success(self, service, mock_pygsheets):
"""
Test successful write of a single draft pick to the sheet.
Verifies:
- Correct cell range is calculated (D + overall + 1)
- Correct data is written (4 columns)
- Returns True on success
"""
result = await service.write_pick(
season=12,
overall=1,
orig_owner_abbrev="HAM",
owner_abbrev="HAM",
player_name="Mike Trout",
swar=8.5
)
assert result is True
# Verify worksheet was accessed
mock_pygsheets['spreadsheet'].worksheet_by_title.assert_called_with("Ordered List")
@pytest.mark.asyncio
async def test_write_pick_disabled(self, service, mock_config):
"""
Test that write_pick returns False when feature is disabled.
Verifies:
- Returns False when draft_sheet_enabled is False
- No API calls are made
"""
mock_config.draft_sheet_enabled = False
result = await service.write_pick(
season=12,
overall=1,
orig_owner_abbrev="HAM",
owner_abbrev="HAM",
player_name="Mike Trout",
swar=8.5
)
assert result is False
@pytest.mark.asyncio
async def test_write_pick_no_sheet_configured(self, service, mock_config):
"""
Test that write_pick returns False when no sheet is configured for season.
Verifies:
- Returns False when get_draft_sheet_key returns None
- No API calls are made
"""
mock_config.get_draft_sheet_key = MagicMock(return_value=None)
result = await service.write_pick(
season=13, # Season 13 has no configured sheet
overall=1,
orig_owner_abbrev="HAM",
owner_abbrev="HAM",
player_name="Mike Trout",
swar=8.5
)
assert result is False
@pytest.mark.asyncio
async def test_write_pick_api_error(self, service, mock_pygsheets):
"""
Test that write_pick returns False and logs error on API failure.
Verifies:
- Returns False on exception
- Exception is caught and logged (not raised)
"""
mock_pygsheets['spreadsheet'].worksheet_by_title.side_effect = Exception("API Error")
result = await service.write_pick(
season=12,
overall=1,
orig_owner_abbrev="HAM",
owner_abbrev="HAM",
player_name="Mike Trout",
swar=8.5
)
assert result is False
# ==================== write_picks_batch() Tests ====================
@pytest.mark.asyncio
async def test_write_picks_batch_success(self, service, mock_pygsheets):
"""
Test successful batch write of multiple picks.
Verifies:
- All picks are written
- Returns correct success/failure counts
"""
picks = [
(1, "HAM", "HAM", "Player 1", 2.5),
(2, "NYY", "NYY", "Player 2", 3.0),
(3, "BOS", "BOS", "Player 3", 1.5),
]
success_count, failure_count = await service.write_picks_batch(
season=12,
picks=picks
)
assert success_count == 3
assert failure_count == 0
@pytest.mark.asyncio
async def test_write_picks_batch_empty_list(self, service):
"""
Test batch write with empty picks list.
Verifies:
- Returns (0, 0) for empty list
- No API calls are made
"""
success_count, failure_count = await service.write_picks_batch(
season=12,
picks=[]
)
assert success_count == 0
assert failure_count == 0
@pytest.mark.asyncio
async def test_write_picks_batch_disabled(self, service, mock_config):
"""
Test batch write when feature is disabled.
Verifies:
- Returns (0, total_picks) when disabled
"""
mock_config.draft_sheet_enabled = False
picks = [
(1, "HAM", "HAM", "Player 1", 2.5),
(2, "NYY", "NYY", "Player 2", 3.0),
]
success_count, failure_count = await service.write_picks_batch(
season=12,
picks=picks
)
assert success_count == 0
assert failure_count == 2
# ==================== clear_picks_range() Tests ====================
@pytest.mark.asyncio
async def test_clear_picks_range_success(self, service, mock_pygsheets):
"""
Test successful clearing of picks range.
Verifies:
- Returns True on success
- Correct range is cleared
"""
result = await service.clear_picks_range(
season=12,
start_overall=1,
end_overall=512
)
assert result is True
@pytest.mark.asyncio
async def test_clear_picks_range_disabled(self, service, mock_config):
"""
Test clearing when feature is disabled.
Verifies:
- Returns False when disabled
"""
mock_config.draft_sheet_enabled = False
result = await service.clear_picks_range(
season=12,
start_overall=1,
end_overall=512
)
assert result is False
# ==================== get_sheet_url() Tests ====================
def test_get_sheet_url_configured(self, service, mock_config):
"""
Test get_sheet_url returns URL when configured.
Verifies:
- Returns correct URL format
"""
url = service.get_sheet_url(season=12)
assert url == "https://docs.google.com/spreadsheets/d/test-sheet-key-123"
def test_get_sheet_url_not_configured(self, service, mock_config):
"""
Test get_sheet_url returns None when not configured.
Verifies:
- Returns None for unconfigured season
"""
mock_config.get_draft_sheet_url = MagicMock(return_value=None)
url = service.get_sheet_url(season=99)
assert url is None
class TestGlobalServiceInstance:
"""
Test suite for the global service instance pattern.
Tests get_draft_sheet_service() lazy initialization.
"""
def test_get_draft_sheet_service_returns_instance(self):
"""
Test that get_draft_sheet_service returns a DraftSheetService instance.
Note: This creates a real service instance but won't make API calls
without being used.
"""
with patch('services.draft_sheet_service.get_config') as mock_config:
mock_config.return_value.sheets_credentials_path = "/test/path.json"
mock_config.return_value.draft_sheet_enabled = True
# Reset global instance
import services.draft_sheet_service as service_module
service_module._draft_sheet_service = None
service = get_draft_sheet_service()
assert isinstance(service, DraftSheetService)
def test_get_draft_sheet_service_returns_same_instance(self):
"""
Test that get_draft_sheet_service returns the same instance on subsequent calls.
Verifies singleton pattern for global service.
"""
with patch('services.draft_sheet_service.get_config') as mock_config:
mock_config.return_value.sheets_credentials_path = "/test/path.json"
mock_config.return_value.draft_sheet_enabled = True
# Reset global instance
import services.draft_sheet_service as service_module
service_module._draft_sheet_service = None
service1 = get_draft_sheet_service()
service2 = get_draft_sheet_service()
assert service1 is service2

View File

@ -0,0 +1,534 @@
"""
Unit tests for draft helper functions in utils/draft_helpers.py.
These tests verify:
1. calculate_pick_details() correctly handles linear and snake draft formats
2. calculate_overall_from_round_position() is the inverse of calculate_pick_details()
3. validate_cap_space() correctly validates roster cap space with team-specific caps
4. Other helper functions work correctly
Why these tests matter:
- Draft pick calculations are critical for correct draft order
- Cap space validation prevents illegal roster configurations
- These functions are used throughout the draft system
"""
import pytest
from utils.draft_helpers import (
calculate_pick_details,
calculate_overall_from_round_position,
validate_cap_space,
format_pick_display,
get_next_pick_overall,
is_draft_complete,
get_round_name,
)
class TestCalculatePickDetails:
"""Tests for calculate_pick_details() function."""
def test_round_1_pick_1(self):
"""
Overall pick 1 should be Round 1, Pick 1.
Why: First pick of the draft is the simplest case.
"""
round_num, position = calculate_pick_details(1)
assert round_num == 1
assert position == 1
def test_round_1_pick_16(self):
"""
Overall pick 16 should be Round 1, Pick 16.
Why: Last pick of round 1 in a 16-team draft.
"""
round_num, position = calculate_pick_details(16)
assert round_num == 1
assert position == 16
def test_round_2_pick_1(self):
"""
Overall pick 17 should be Round 2, Pick 1.
Why: First pick of round 2 (linear format for rounds 1-10).
"""
round_num, position = calculate_pick_details(17)
assert round_num == 2
assert position == 1
def test_round_10_pick_16(self):
"""
Overall pick 160 should be Round 10, Pick 16.
Why: Last pick of linear draft section.
"""
round_num, position = calculate_pick_details(160)
assert round_num == 10
assert position == 16
def test_round_11_pick_1_snake_begins(self):
"""
Overall pick 161 should be Round 11, Pick 1.
Why: First pick of snake draft. Same team as Round 10 Pick 16
gets first pick of Round 11.
"""
round_num, position = calculate_pick_details(161)
assert round_num == 11
assert position == 1
def test_round_11_pick_16(self):
"""
Overall pick 176 should be Round 11, Pick 16.
Why: Last pick of round 11 (odd snake round = forward order).
"""
round_num, position = calculate_pick_details(176)
assert round_num == 11
assert position == 16
def test_round_12_snake_reverses(self):
"""
Round 12 should be in reverse order (snake).
Why: Even rounds in snake draft reverse the order.
"""
# Pick 177 = Round 12, Pick 16 (starts with last team)
round_num, position = calculate_pick_details(177)
assert round_num == 12
assert position == 16
# Pick 192 = Round 12, Pick 1 (ends with first team)
round_num, position = calculate_pick_details(192)
assert round_num == 12
assert position == 1
class TestCalculateOverallFromRoundPosition:
"""Tests for calculate_overall_from_round_position() function."""
def test_round_1_pick_1(self):
"""Round 1, Pick 1 should be overall pick 1."""
overall = calculate_overall_from_round_position(1, 1)
assert overall == 1
def test_round_1_pick_16(self):
"""Round 1, Pick 16 should be overall pick 16."""
overall = calculate_overall_from_round_position(1, 16)
assert overall == 16
def test_round_10_pick_16(self):
"""Round 10, Pick 16 should be overall pick 160."""
overall = calculate_overall_from_round_position(10, 16)
assert overall == 160
def test_round_11_pick_1(self):
"""Round 11, Pick 1 should be overall pick 161."""
overall = calculate_overall_from_round_position(11, 1)
assert overall == 161
def test_round_12_pick_16_snake(self):
"""Round 12, Pick 16 should be overall pick 177 (snake reverses)."""
overall = calculate_overall_from_round_position(12, 16)
assert overall == 177
def test_inverse_relationship_linear(self):
"""
calculate_overall_from_round_position should be inverse of calculate_pick_details
for linear rounds (1-10).
Why: These functions must be inverses for draft logic to work correctly.
"""
for overall in range(1, 161): # All linear picks
round_num, position = calculate_pick_details(overall)
calculated_overall = calculate_overall_from_round_position(round_num, position)
assert calculated_overall == overall, f"Failed for overall={overall}"
def test_inverse_relationship_snake(self):
"""
calculate_overall_from_round_position should be inverse of calculate_pick_details
for snake rounds (11+).
Why: These functions must be inverses for draft logic to work correctly.
"""
for overall in range(161, 257): # First 6 snake rounds
round_num, position = calculate_pick_details(overall)
calculated_overall = calculate_overall_from_round_position(round_num, position)
assert calculated_overall == overall, f"Failed for overall={overall}"
class TestValidateCapSpace:
"""Tests for validate_cap_space() function."""
@pytest.mark.asyncio
async def test_valid_under_cap(self):
"""
Drafting a player that keeps team under cap should be valid.
Why: Normal case - team is under cap and pick should be allowed.
The 26 cheapest players are summed (all 3 in this case since < 26).
"""
roster = {
'active': {
'players': [
{'id': 1, 'name': 'Player 1', 'wara': 5.0},
{'id': 2, 'name': 'Player 2', 'wara': 4.0},
],
'WARa': 9.0
}
}
new_player_wara = 3.0
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
assert is_valid is True
assert projected_total == 12.0 # 3 + 4 + 5 (all players, sorted ascending)
assert cap_limit == 32.0 # Default cap
@pytest.mark.asyncio
async def test_invalid_over_cap(self):
"""
Drafting a player that puts team over cap should be invalid.
Why: Must prevent illegal roster configurations.
With 26 players all at 1.5 WAR, sum = 39.0 which exceeds 32.0 cap.
"""
# Create roster with 25 players at 1.5 WAR each
players = [{'id': i, 'name': f'Player {i}', 'wara': 1.5} for i in range(25)]
roster = {
'active': {
'players': players,
'WARa': 37.5 # 25 * 1.5
}
}
new_player_wara = 1.5 # Adding another 1.5 player = 26 * 1.5 = 39.0
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
assert is_valid is False
assert projected_total == 39.0 # 26 * 1.5
assert cap_limit == 32.0
@pytest.mark.asyncio
async def test_team_specific_cap(self):
"""
Should use team's custom salary cap when provided.
Why: Some teams have different caps (expansion, penalties, etc.)
"""
roster = {
'active': {
'players': [
{'id': 1, 'name': 'Player 1', 'wara': 10.0},
{'id': 2, 'name': 'Player 2', 'wara': 10.0},
],
'WARa': 20.0
}
}
team = {'abbrev': 'EXP', 'salary_cap': 25.0} # Expansion team with lower cap
new_player_wara = 6.0 # Total = 26.0 which exceeds 25.0 cap
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara, team)
assert is_valid is False # Over custom 25.0 cap
assert projected_total == 26.0 # 6 + 10 + 10 (sorted ascending)
assert cap_limit == 25.0
@pytest.mark.asyncio
async def test_team_with_none_cap_uses_default(self):
"""
Team with salary_cap=None should use default cap.
Why: Backwards compatibility for teams without custom caps.
"""
roster = {
'active': {
'players': [
{'id': 1, 'name': 'Player 1', 'wara': 10.0},
],
'WARa': 10.0
}
}
team = {'abbrev': 'STD', 'salary_cap': None}
new_player_wara = 5.0
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara, team)
assert is_valid is True
assert projected_total == 15.0 # 5 + 10
assert cap_limit == 32.0 # Default
@pytest.mark.asyncio
async def test_cap_counting_logic_cheapest_26(self):
"""
Only the 26 CHEAPEST players should count toward cap.
Why: League rules - expensive stars can be "excluded" if you have
enough cheap depth players. This rewards roster construction.
"""
# Create 27 players: 1 expensive star (10.0) and 26 cheap players (1.0 each)
players = [{'id': 0, 'name': 'Star', 'wara': 10.0}] # Expensive star
for i in range(1, 27):
players.append({'id': i, 'name': f'Cheap {i}', 'wara': 1.0})
roster = {
'active': {
'players': players,
'WARa': sum(p['wara'] for p in players) # 10 + 26 = 36
}
}
new_player_wara = 1.0 # Adding another cheap player
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
# With 28 players total, only cheapest 26 count
# Sorted ascending: 27 players at 1.0, then 1 at 10.0
# Cheapest 26 = 26 * 1.0 = 26.0 (the star is EXCLUDED)
assert is_valid is True
assert projected_total == 26.0
assert cap_limit == 32.0
@pytest.mark.asyncio
async def test_invalid_roster_structure(self):
"""
Invalid roster structure should raise ValueError.
Why: Defensive programming - catch malformed data early.
"""
with pytest.raises(ValueError, match="Invalid roster structure"):
await validate_cap_space({}, 1.0)
with pytest.raises(ValueError, match="Invalid roster structure"):
await validate_cap_space(None, 1.0)
with pytest.raises(ValueError, match="Invalid roster structure"):
await validate_cap_space({'other': {}}, 1.0)
@pytest.mark.asyncio
async def test_empty_roster(self):
"""
Empty roster should allow any player (well under cap).
Why: First pick of draft has empty roster.
"""
roster = {
'active': {
'players': [],
'WARa': 0.0
}
}
new_player_wara = 5.0
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
assert is_valid is True
assert projected_total == 5.0
@pytest.mark.asyncio
async def test_tolerance_boundary(self):
"""
Values at or just below cap + tolerance should be valid.
Why: Floating point tolerance prevents false positives.
"""
# Create 25 players at 1.28 WAR each = 32.0 total
players = [{'id': i, 'name': f'Player {i}', 'wara': 1.28} for i in range(25)]
roster = {
'active': {
'players': players,
'WARa': 32.0
}
}
# Adding 0.0 WAR player keeps us at exactly cap - should be valid
is_valid, projected_total, _ = await validate_cap_space(roster, 0.0)
assert is_valid is True
assert abs(projected_total - 32.0) < 0.01
# Adding 0.002 WAR player puts us just over tolerance - should be invalid
is_valid, _, _ = await validate_cap_space(roster, 0.003)
assert is_valid is False
@pytest.mark.asyncio
async def test_star_exclusion_scenario(self):
"""
Test realistic scenario where an expensive star is excluded from cap.
Why: This is the key feature - teams can build around stars by
surrounding them with cheap depth players.
"""
# 26 cheap players at 1.0 WAR each
players = [{'id': i, 'name': f'Depth {i}', 'wara': 1.0} for i in range(26)]
roster = {
'active': {
'players': players,
'WARa': 26.0
}
}
# Drafting a 10.0 WAR superstar
# With 27 players, cheapest 26 count = 26 * 1.0 = 26.0 (star excluded!)
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 10.0)
assert is_valid is True
assert projected_total == 26.0 # Star is excluded from cap calculation
assert cap_limit == 32.0
class TestFormatPickDisplay:
"""Tests for format_pick_display() function."""
def test_format_pick_1(self):
"""First pick should display correctly."""
result = format_pick_display(1)
assert result == "Round 1, Pick 1 (Overall #1)"
def test_format_pick_45(self):
"""Middle pick should display correctly."""
result = format_pick_display(45)
assert "Round 3" in result
assert "Overall #45" in result
def test_format_pick_161(self):
"""First snake pick should display correctly."""
result = format_pick_display(161)
assert "Round 11" in result
assert "Overall #161" in result
class TestGetNextPickOverall:
"""Tests for get_next_pick_overall() function."""
def test_next_pick(self):
"""Next pick should increment by 1."""
assert get_next_pick_overall(1) == 2
assert get_next_pick_overall(160) == 161
assert get_next_pick_overall(512) == 513
class TestIsDraftComplete:
"""Tests for is_draft_complete() function."""
def test_draft_not_complete(self):
"""Draft should not be complete before total picks."""
assert is_draft_complete(1, total_picks=512) is False
assert is_draft_complete(511, total_picks=512) is False
assert is_draft_complete(512, total_picks=512) is False
def test_draft_complete(self):
"""Draft should be complete after total picks."""
assert is_draft_complete(513, total_picks=512) is True
assert is_draft_complete(600, total_picks=512) is True
class TestGetRoundName:
"""Tests for get_round_name() function."""
def test_round_1(self):
"""Round 1 should just say 'Round 1'."""
assert get_round_name(1) == "Round 1"
def test_round_11_snake_begins(self):
"""Round 11 should indicate snake draft begins."""
result = get_round_name(11)
assert "Round 11" in result
assert "Snake" in result
def test_regular_round(self):
"""Regular rounds should just show round number."""
assert get_round_name(5) == "Round 5"
assert get_round_name(20) == "Round 20"
class TestRealTeamModelIntegration:
"""Integration tests using the actual Team Pydantic model."""
@pytest.mark.asyncio
async def test_validate_cap_space_with_real_team_model(self):
"""
validate_cap_space should work with real Team Pydantic model.
Why: End-to-end test with actual production model.
"""
from models.team import Team
roster = {
'active': {
'players': [
{'id': 1, 'name': 'Star', 'wara': 8.0},
{'id': 2, 'name': 'Good', 'wara': 4.0},
],
'WARa': 12.0
}
}
# Team with custom cap of 20.0
team = Team(
id=1,
abbrev='EXP',
sname='Expansion',
lname='Expansion Team',
season=12,
salary_cap=20.0
)
# Adding 10.0 WAR player: sorted ascending [4.0, 8.0, 10.0] = 22.0 total
# 22.0 > 20.0 cap, so invalid
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 10.0, team)
assert is_valid is False
assert projected_total == 22.0 # 4 + 8 + 10
assert cap_limit == 20.0
# Adding 5.0 WAR player: sorted ascending [4.0, 5.0, 8.0] = 17.0 total
# 17.0 < 20.0 cap, so valid
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 5.0, team)
assert is_valid is True
assert projected_total == 17.0 # 4 + 5 + 8
assert cap_limit == 20.0
@pytest.mark.asyncio
async def test_realistic_draft_scenario(self):
"""
Test a realistic draft scenario where team has built around stars.
Why: Validates the complete workflow with real Team model and
demonstrates the cap exclusion mechanic working as intended.
"""
from models.team import Team
# Team has 2 superstars (8.0, 7.0) and 25 cheap depth players (1.0 each)
players = [
{'id': 0, 'name': 'Superstar 1', 'wara': 8.0},
{'id': 1, 'name': 'Superstar 2', 'wara': 7.0},
]
for i in range(2, 27):
players.append({'id': i, 'name': f'Depth {i}', 'wara': 1.0})
roster = {
'active': {
'players': players,
'WARa': sum(p['wara'] for p in players) # 8 + 7 + 25 = 40.0
}
}
team = Team(
id=1,
abbrev='STR',
sname='Stars',
lname='All-Stars Team',
season=12,
salary_cap=None # Use default 32.0
)
# Draft another 1.0 WAR depth player
# With 28 players, only cheapest 26 count
# Sorted: [1.0 x 26, 7.0, 8.0] - cheapest 26 = 26 * 1.0 = 26.0
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 1.0, team)
assert is_valid is True
assert projected_total == 26.0 # Both superstars excluded!
assert cap_limit == 32.0

421
tests/test_utils_helpers.py Normal file
View File

@ -0,0 +1,421 @@
"""
Unit tests for salary cap helper functions in utils/helpers.py.
These tests verify:
1. get_team_salary_cap() returns correct cap values with fallback behavior
2. exceeds_salary_cap() correctly identifies when WAR exceeds team cap
3. Edge cases around None values and floating point tolerance
Why these tests matter:
- Salary cap validation is critical for league integrity during trades/drafts
- The helper functions centralize logic previously scattered across commands
- Proper fallback behavior ensures backwards compatibility
"""
import pytest
from utils.helpers import (
DEFAULT_SALARY_CAP,
SALARY_CAP_TOLERANCE,
get_team_salary_cap,
exceeds_salary_cap
)
class TestGetTeamSalaryCap:
"""Tests for get_team_salary_cap() function."""
def test_returns_team_salary_cap_when_set(self):
"""
When a team has a custom salary_cap value set, return that value.
Why: Some teams may have different caps (expansion teams, penalties, etc.)
"""
team = {'abbrev': 'TEST', 'salary_cap': 35.0}
result = get_team_salary_cap(team)
assert result == 35.0
def test_returns_default_when_salary_cap_is_none(self):
"""
When team.salary_cap is None, return the default cap (32.0).
Why: Most teams use the standard cap; None indicates no custom value.
"""
team = {'abbrev': 'TEST', 'salary_cap': None}
result = get_team_salary_cap(team)
assert result == DEFAULT_SALARY_CAP
assert result == 32.0
def test_returns_default_when_salary_cap_key_missing(self):
"""
When the salary_cap key doesn't exist in team dict, return default.
Why: Backwards compatibility with older team data structures.
"""
team = {'abbrev': 'TEST', 'sname': 'Test Team'}
result = get_team_salary_cap(team)
assert result == DEFAULT_SALARY_CAP
def test_returns_default_when_team_is_none(self):
"""
When team is None, return the default cap.
Why: Defensive programming - callers may pass None in edge cases.
"""
result = get_team_salary_cap(None)
assert result == DEFAULT_SALARY_CAP
def test_returns_default_when_team_is_empty_dict(self):
"""
When team is an empty dict, return the default cap.
Why: Edge case handling for malformed team data.
"""
result = get_team_salary_cap({})
assert result == DEFAULT_SALARY_CAP
def test_respects_zero_salary_cap(self):
"""
When salary_cap is explicitly 0, return 0 (not default).
Why: Zero is a valid value (e.g., suspended team), distinct from None.
"""
team = {'abbrev': 'BANNED', 'salary_cap': 0.0}
result = get_team_salary_cap(team)
assert result == 0.0
def test_handles_integer_salary_cap(self):
"""
When salary_cap is an integer, return it as-is.
Why: API may return int instead of float; function should handle both.
"""
team = {'abbrev': 'TEST', 'salary_cap': 30}
result = get_team_salary_cap(team)
assert result == 30
class TestExceedsSalaryCap:
"""Tests for exceeds_salary_cap() function."""
def test_returns_false_when_under_cap(self):
"""
WAR of 30.0 should not exceed default cap of 32.0.
Why: Normal case - team is under cap and should pass validation.
"""
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
result = exceeds_salary_cap(30.0, team)
assert result is False
def test_returns_false_when_exactly_at_cap(self):
"""
WAR of exactly 32.0 should not exceed cap (within tolerance).
Why: Teams should be allowed to be exactly at cap limit.
"""
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
result = exceeds_salary_cap(32.0, team)
assert result is False
def test_returns_false_within_tolerance(self):
"""
WAR slightly above cap but within tolerance should not exceed.
Why: Floating point math may produce values like 32.0000001;
tolerance prevents false positives from rounding errors.
"""
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
# 32.0005 is within 0.001 tolerance of 32.0
result = exceeds_salary_cap(32.0005, team)
assert result is False
def test_returns_true_when_over_cap(self):
"""
WAR of 33.0 clearly exceeds cap of 32.0.
Why: Core validation - must reject teams over cap.
"""
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
result = exceeds_salary_cap(33.0, team)
assert result is True
def test_returns_true_just_over_tolerance(self):
"""
WAR just beyond tolerance should exceed cap.
Why: Tolerance has a boundary; values beyond it must fail.
"""
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
# 32.002 is beyond 0.001 tolerance
result = exceeds_salary_cap(32.002, team)
assert result is True
def test_uses_team_custom_cap(self):
"""
Should use team's custom cap, not default.
Why: Teams with higher/lower caps must be validated correctly.
"""
team = {'abbrev': 'EXPANSION', 'salary_cap': 28.0}
# 30.0 is under default 32.0 but over custom 28.0
result = exceeds_salary_cap(30.0, team)
assert result is True
def test_uses_default_cap_when_team_cap_none(self):
"""
When team has no custom cap, use default for comparison.
Why: Backwards compatibility - existing teams without salary_cap field.
"""
team = {'abbrev': 'TEST', 'salary_cap': None}
result = exceeds_salary_cap(33.0, team)
assert result is True
result = exceeds_salary_cap(31.0, team)
assert result is False
def test_handles_none_team(self):
"""
When team is None, use default cap for comparison.
Why: Defensive programming for edge cases.
"""
result = exceeds_salary_cap(33.0, None)
assert result is True
result = exceeds_salary_cap(31.0, None)
assert result is False
class TestPydanticModelSupport:
"""Tests for Pydantic model support in helper functions."""
def test_get_team_salary_cap_with_pydantic_model(self):
"""
Should work with Pydantic models that have salary_cap attribute.
Why: Team objects in the codebase are often Pydantic models,
not just dicts. The helper must support both.
"""
class MockTeam:
salary_cap = 35.0
abbrev = 'TEST'
team = MockTeam()
result = get_team_salary_cap(team)
assert result == 35.0
def test_get_team_salary_cap_with_pydantic_model_none_cap(self):
"""
Pydantic model with salary_cap=None should return default.
Why: Many existing Team objects have salary_cap=None.
"""
class MockTeam:
salary_cap = None
abbrev = 'TEST'
team = MockTeam()
result = get_team_salary_cap(team)
assert result == DEFAULT_SALARY_CAP
def test_get_team_salary_cap_with_object_missing_attribute(self):
"""
Object without salary_cap attribute should return default.
Why: Defensive handling for objects that don't have the attribute.
"""
class MockTeam:
abbrev = 'TEST'
team = MockTeam()
result = get_team_salary_cap(team)
assert result == DEFAULT_SALARY_CAP
def test_exceeds_salary_cap_with_pydantic_model(self):
"""
exceeds_salary_cap should work with Pydantic-like objects.
Why: Draft and transaction code passes Team objects directly.
"""
class MockTeam:
salary_cap = 28.0
abbrev = 'EXPANSION'
team = MockTeam()
# 30.0 exceeds custom cap of 28.0
result = exceeds_salary_cap(30.0, team)
assert result is True
# 27.0 does not exceed custom cap of 28.0
result = exceeds_salary_cap(27.0, team)
assert result is False
class TestEdgeCases:
"""Tests for edge cases and boundary conditions."""
def test_negative_salary_cap(self):
"""
Negative salary cap should be returned as-is (even if nonsensical).
Why: Function should not validate business logic - just return the value.
If someone sets a negative cap, that's a data issue, not a helper issue.
"""
team = {'abbrev': 'BROKE', 'salary_cap': -5.0}
result = get_team_salary_cap(team)
assert result == -5.0
def test_negative_wara_under_cap(self):
"""
Negative WAR should not exceed any positive cap.
Why: Teams with negative WAR (all bad players) are clearly under cap.
"""
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
result = exceeds_salary_cap(-10.0, team)
assert result is False
def test_negative_wara_with_negative_cap(self):
"""
Negative WAR vs negative cap - WAR higher than cap exceeds it.
Why: Edge case where both values are negative. -3.0 > -5.0 + 0.001
"""
team = {'abbrev': 'BROKE', 'salary_cap': -5.0}
# -3.0 > -4.999 (which is -5.0 + 0.001), so it exceeds
result = exceeds_salary_cap(-3.0, team)
assert result is True
# -6.0 < -4.999, so it does not exceed
result = exceeds_salary_cap(-6.0, team)
assert result is False
def test_very_large_salary_cap(self):
"""
Very large salary cap values should work correctly.
Why: Ensure no overflow or precision issues with large numbers.
"""
team = {'abbrev': 'RICH', 'salary_cap': 1000000.0}
result = get_team_salary_cap(team)
assert result == 1000000.0
result = exceeds_salary_cap(999999.0, team)
assert result is False
result = exceeds_salary_cap(1000001.0, team)
assert result is True
def test_very_small_salary_cap(self):
"""
Very small (but positive) salary cap should work.
Why: Some hypothetical penalty scenario with tiny cap.
"""
team = {'abbrev': 'TINY', 'salary_cap': 0.5}
result = exceeds_salary_cap(0.4, team)
assert result is False
result = exceeds_salary_cap(0.6, team)
assert result is True
def test_float_precision_boundary(self):
"""
Test exact boundary of tolerance (cap + 0.001).
Why: Ensure the boundary condition is handled correctly.
The check is wara > (cap + tolerance), so exactly at boundary should NOT exceed.
"""
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
# Exactly at cap + tolerance = 32.001
result = exceeds_salary_cap(32.001, team)
assert result is False # Not greater than, equal to
# Just barely over
result = exceeds_salary_cap(32.0011, team)
assert result is True
class TestRealTeamModel:
"""Tests using the actual Team Pydantic model from models/team.py."""
def test_with_real_team_model(self):
"""
Test with the actual Team Pydantic model used in production.
Why: Ensures the helper works with real Team objects, not just mocks.
"""
from models.team import Team
team = Team(
id=1,
abbrev='TEST',
sname='Test Team',
lname='Test Team Long Name',
season=12,
salary_cap=28.5
)
result = get_team_salary_cap(team)
assert result == 28.5
def test_with_real_team_model_none_cap(self):
"""
Real Team model with salary_cap=None should use default.
Why: This is the most common case in production.
"""
from models.team import Team
team = Team(
id=2,
abbrev='STD',
sname='Standard Team',
lname='Standard Team Long Name',
season=12,
salary_cap=None
)
result = get_team_salary_cap(team)
assert result == DEFAULT_SALARY_CAP
def test_exceeds_with_real_team_model(self):
"""
exceeds_salary_cap with real Team model.
Why: End-to-end test with actual production model.
"""
from models.team import Team
team = Team(
id=3,
abbrev='EXP',
sname='Expansion',
lname='Expansion Team',
season=12,
salary_cap=28.0
)
# 30.0 exceeds 28.0 cap
assert exceeds_salary_cap(30.0, team) is True
# 27.0 does not exceed 28.0 cap
assert exceeds_salary_cap(27.0, team) is False
class TestConstants:
"""Tests for salary cap constants."""
def test_default_salary_cap_value(self):
"""
DEFAULT_SALARY_CAP should be 32.0 (league standard).
Why: Ensures constant wasn't accidentally changed.
"""
assert DEFAULT_SALARY_CAP == 32.0
def test_tolerance_value(self):
"""
SALARY_CAP_TOLERANCE should be 0.001.
Why: Tolerance must be small enough to catch real violations
but large enough to handle floating point imprecision.
"""
assert SALARY_CAP_TOLERANCE == 0.001

View File

@ -110,15 +110,16 @@ def calculate_overall_from_round_position(round_num: int, position: int) -> int:
async def validate_cap_space( async def validate_cap_space(
roster: dict, roster: dict,
new_player_wara: float new_player_wara: float,
) -> Tuple[bool, float]: team=None
) -> Tuple[bool, float, float]:
""" """
Validate team has cap space to draft player. Validate team has cap space to draft player.
Cap calculation: Cap calculation:
- Maximum 32 players on active roster - Maximum 32 players on active roster
- Only top 26 players count toward cap - Only top 26 players count toward cap
- Cap limit: 32.00 sWAR total - Cap limit: Team-specific or default 32.00 sWAR
Args: Args:
roster: Roster dictionary from API with structure: roster: Roster dictionary from API with structure:
@ -129,15 +130,18 @@ async def validate_cap_space(
} }
} }
new_player_wara: sWAR value of player being drafted new_player_wara: sWAR value of player being drafted
team: Optional team object/dict for team-specific salary cap
Returns: Returns:
(valid, projected_total): True if under cap, projected total sWAR after addition (valid, projected_total, cap_limit): True if under cap, projected total sWAR, and cap limit used
Raises: Raises:
ValueError: If roster structure is invalid ValueError: If roster structure is invalid
""" """
from utils.helpers import get_team_salary_cap, SALARY_CAP_TOLERANCE
config = get_config() config = get_config()
cap_limit = config.swar_cap_limit cap_limit = get_team_salary_cap(team)
cap_player_count = config.cap_player_count cap_player_count = config.cap_player_count
if not roster or not roster.get('active'): if not roster or not roster.get('active'):
@ -150,31 +154,34 @@ async def validate_cap_space(
current_roster_size = len(current_players) current_roster_size = len(current_players)
projected_roster_size = current_roster_size + 1 projected_roster_size = current_roster_size + 1
# Maximum zeroes = 32 - roster size # Cap counting rules:
# Maximum counted = 26 - zeroes # - The 26 CHEAPEST (lowest WAR) players on the roster count toward the cap
max_zeroes = 32 - projected_roster_size # - If roster has fewer than 26 players, all of them count
max_counted = min(cap_player_count, cap_player_count - max_zeroes) # Can't count more than cap_player_count # - If roster has 26+ players, only the bottom 26 by WAR count
# - This allows expensive stars to be "excluded" if you have enough cheap depth
players_counted = min(projected_roster_size, cap_player_count)
# Sort all players (including new) by sWAR descending # Sort all players (including new) by sWAR ASCENDING (cheapest first)
all_players_wara = [p['wara'] for p in current_players] + [new_player_wara] all_players_wara = [p['wara'] for p in current_players] + [new_player_wara]
sorted_wara = sorted(all_players_wara, reverse=True) sorted_wara = sorted(all_players_wara) # Ascending order
# Sum top N players # Sum bottom N players (the cheapest ones)
projected_total = sum(sorted_wara[:max_counted]) projected_total = sum(sorted_wara[:players_counted])
# Allow tiny floating point tolerance # Allow tiny floating point tolerance
is_valid = projected_total <= (cap_limit + 0.00001) is_valid = projected_total <= (cap_limit + SALARY_CAP_TOLERANCE)
logger.debug( logger.debug(
f"Cap validation: roster_size={current_roster_size}, " f"Cap validation: roster_size={current_roster_size}, "
f"projected_size={projected_roster_size}, " f"projected_size={projected_roster_size}, "
f"max_counted={max_counted}, " f"players_counted={players_counted}, "
f"new_player_wara={new_player_wara:.2f}, " f"new_player_wara={new_player_wara:.2f}, "
f"projected_total={projected_total:.2f}, " f"projected_total={projected_total:.2f}, "
f"cap_limit={cap_limit:.2f}, "
f"valid={is_valid}" f"valid={is_valid}"
) )
return is_valid, projected_total return is_valid, projected_total, cap_limit
def format_pick_display(overall: int) -> str: def format_pick_display(overall: int) -> str:

59
utils/helpers.py Normal file
View File

@ -0,0 +1,59 @@
"""
Helper functions for Discord Bot v2.0
Contains utility functions for salary cap calculations and other common operations.
"""
from typing import Union
from config import get_config
# Get default values from config
_config = get_config()
# Salary cap constants - default from config, tolerance for float comparisons
DEFAULT_SALARY_CAP = _config.swar_cap_limit # 32.0
SALARY_CAP_TOLERANCE = 0.001 # Small tolerance for floating point comparisons
def get_team_salary_cap(team) -> float:
"""
Get the salary cap for a team, falling back to the default if not set.
Args:
team: Team data - can be a dict or Pydantic model with 'salary_cap' attribute.
Returns:
float: The team's salary cap, or DEFAULT_SALARY_CAP (32.0) if not set.
Why: Teams may have custom salary caps (e.g., for expansion teams or penalties).
This centralizes the fallback logic so all cap checks use the same source of truth.
"""
if team is None:
return DEFAULT_SALARY_CAP
# Handle both dict and Pydantic model (or any object with salary_cap attribute)
if isinstance(team, dict):
salary_cap = team.get('salary_cap')
else:
salary_cap = getattr(team, 'salary_cap', None)
if salary_cap is not None:
return salary_cap
return DEFAULT_SALARY_CAP
def exceeds_salary_cap(wara: float, team) -> bool:
"""
Check if a WAR total exceeds the team's salary cap.
Args:
wara: The total WAR value to check
team: Team data - can be a dict or Pydantic model
Returns:
bool: True if wara exceeds the team's salary cap (with tolerance)
Why: Centralizes the salary cap comparison logic with proper floating point
tolerance handling. All cap validation should use this function.
"""
cap = get_team_salary_cap(team)
return wara > (cap + SALARY_CAP_TOLERANCE)

View File

@ -23,7 +23,8 @@ async def create_on_the_clock_embed(
draft_data: DraftData, draft_data: DraftData,
recent_picks: List[DraftPick], recent_picks: List[DraftPick],
upcoming_picks: List[DraftPick], upcoming_picks: List[DraftPick],
team_roster_swar: Optional[float] = None team_roster_swar: Optional[float] = None,
sheet_url: Optional[str] = None
) -> discord.Embed: ) -> discord.Embed:
""" """
Create "on the clock" embed showing current pick info. Create "on the clock" embed showing current pick info.
@ -34,6 +35,7 @@ async def create_on_the_clock_embed(
recent_picks: List of recent draft picks recent_picks: List of recent draft picks
upcoming_picks: List of upcoming draft picks upcoming_picks: List of upcoming draft picks
team_roster_swar: Current team sWAR (optional) team_roster_swar: Current team sWAR (optional)
sheet_url: Optional Google Sheets URL for draft tracking
Returns: Returns:
Discord embed with pick information Discord embed with pick information
@ -67,10 +69,11 @@ async def create_on_the_clock_embed(
# Add team sWAR if provided # Add team sWAR if provided
if team_roster_swar is not None: if team_roster_swar is not None:
config = get_config() from utils.helpers import get_team_salary_cap
cap_limit = get_team_salary_cap(current_pick.owner)
embed.add_field( embed.add_field(
name="Current sWAR", name="Current sWAR",
value=f"{team_roster_swar:.2f} / {config.swar_cap_limit:.2f}", value=f"{team_roster_swar:.2f} / {cap_limit:.2f}",
inline=True inline=True
) )
@ -99,6 +102,14 @@ async def create_on_the_clock_embed(
inline=False inline=False
) )
# Draft Sheet link
if sheet_url:
embed.add_field(
name="📊 Draft Sheet",
value=f"[View Full Board]({sheet_url})",
inline=False
)
# Add footer # Add footer
if current_pick.is_traded: if current_pick.is_traded:
embed.set_footer(text="📝 This pick was traded") embed.set_footer(text="📝 This pick was traded")
@ -109,7 +120,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.
@ -118,14 +130,22 @@ 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
""" """
embed = EmbedTemplate.info( # Use warning color if paused
title="Draft Status", if draft_data.paused:
description=f"Currently on {format_pick_display(draft_data.currentpick)}" embed = EmbedTemplate.warning(
) title="Draft Status - PAUSED",
description=f"Currently on {format_pick_display(draft_data.currentpick)}"
)
else:
embed = EmbedTemplate.info(
title="Draft Status",
description=f"Currently on {format_pick_display(draft_data.currentpick)}"
)
# On the clock # On the clock
if current_pick.owner: if current_pick.owner:
@ -135,8 +155,13 @@ async def create_draft_status_embed(
inline=True inline=True
) )
# Timer status # Timer status (show paused state prominently)
timer_status = "✅ Active" if draft_data.timer else "⏹️ Inactive" if draft_data.paused:
timer_status = "⏸️ PAUSED"
elif draft_data.timer:
timer_status = "✅ Active"
else:
timer_status = "⏹️ Inactive"
embed.add_field( embed.add_field(
name="Timer", name="Timer",
value=f"{timer_status} ({draft_data.pick_minutes} min)", value=f"{timer_status} ({draft_data.pick_minutes} min)",
@ -158,6 +183,14 @@ async def create_draft_status_embed(
inline=True inline=True
) )
# Pause status (if paused, show prominent warning)
if draft_data.paused:
embed.add_field(
name="Pause Status",
value="🚫 **Draft is paused** - No picks allowed until admin resumes",
inline=False
)
# Lock status # Lock status
embed.add_field( embed.add_field(
name="Lock Status", name="Lock Status",
@ -165,6 +198,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
@ -258,14 +299,15 @@ async def create_draft_list_embed(
inline=False inline=False
) )
embed.set_footer(text="Use /draft-list to manage your auto-draft queue") embed.set_footer(text="Commands: /draft-list-add, /draft-list-remove, /draft-list-clear")
return embed return embed
async def create_draft_board_embed( async def create_draft_board_embed(
round_num: int, round_num: int,
picks: List[DraftPick] picks: List[DraftPick],
sheet_url: Optional[str] = None
) -> discord.Embed: ) -> discord.Embed:
""" """
Create draft board embed showing all picks in a round. Create draft board embed showing all picks in a round.
@ -273,6 +315,7 @@ async def create_draft_board_embed(
Args: Args:
round_num: Round number round_num: Round number
picks: List of DraftPick for this round picks: List of DraftPick for this round
sheet_url: Optional Google Sheets URL for draft tracking
Returns: Returns:
Discord embed with draft board Discord embed with draft board
@ -299,7 +342,10 @@ async def create_draft_board_embed(
player_display = "TBD" player_display = "TBD"
team_display = pick.owner.abbrev if pick.owner else "???" team_display = pick.owner.abbrev if pick.owner else "???"
picks_str += f"**Pick {pick.overall % 16 or 16}:** {team_display} - {player_display}\n" round_pick = pick.overall % 16 or 16
# Format: `RR.PP (#OOO)` - padded for alignment (rounds 1-99, picks 1-16, overall 1-999)
pick_info = f"{round_num:>2}.{round_pick:<2} (#{pick.overall:>3})"
picks_str += f"`{pick_info}` {team_display} - {player_display}\n"
embed.add_field( embed.add_field(
name="Picks", name="Picks",
@ -307,6 +353,14 @@ async def create_draft_board_embed(
inline=False inline=False
) )
# Draft Sheet link
if sheet_url:
embed.add_field(
name="Draft Sheet",
value=f"[View Full Board]({sheet_url})",
inline=False
)
embed.set_footer(text="Use /draft-board [round] to view different rounds") embed.set_footer(text="Use /draft-board [round] to view different rounds")
return embed return embed
@ -345,7 +399,8 @@ async def create_pick_success_embed(
player: Player, player: Player,
team: Team, team: Team,
pick_overall: int, pick_overall: int,
projected_swar: float projected_swar: float,
cap_limit: float | None = None
) -> discord.Embed: ) -> discord.Embed:
""" """
Create embed for successful pick. Create embed for successful pick.
@ -355,33 +410,44 @@ async def create_pick_success_embed(
team: Team that drafted player team: Team that drafted player
pick_overall: Overall pick number pick_overall: Overall pick number
projected_swar: Projected team sWAR after pick projected_swar: Projected team sWAR after pick
cap_limit: Team's salary cap limit (optional, uses helper if not provided)
Returns: Returns:
Discord success embed Discord success embed
""" """
from utils.helpers import get_team_salary_cap
embed = EmbedTemplate.success( embed = EmbedTemplate.success(
title="Pick Confirmed", title=f"{team.sname} select **{player.name}**",
description=f"{team.abbrev} selects **{player.name}**" description=format_pick_display(pick_overall)
) )
if team.thumbnail is not None:
embed.set_thumbnail(url=team.thumbnail)
embed.set_image(url=player.image)
embed.add_field( embed.add_field(
name="Pick", name="Player ID",
value=format_pick_display(pick_overall), value=f"{player.id}",
inline=True inline=True
) )
if hasattr(player, 'wara') and player.wara is not None: if hasattr(player, 'wara') and player.wara is not None:
embed.add_field( embed.add_field(
name="Player sWAR", name="sWAR",
value=f"{player.wara:.2f}", value=f"{player.wara:.2f}",
inline=True inline=True
) )
config = get_config() # Use provided cap_limit or get from team
if cap_limit is None:
cap_limit = get_team_salary_cap(team)
embed.add_field( embed.add_field(
name="Projected Team sWAR", name="Projected Team sWAR",
value=f"{projected_swar:.2f} / {config.swar_cap_limit:.2f}", value=f"{projected_swar:.2f} / {cap_limit:.2f}",
inline=True inline=False
) )
return embed return embed
@ -389,7 +455,8 @@ async def create_pick_success_embed(
async def create_admin_draft_info_embed( async def create_admin_draft_info_embed(
draft_data: DraftData, draft_data: DraftData,
current_pick: Optional[DraftPick] = None current_pick: Optional[DraftPick] = None,
sheet_url: Optional[str] = None
) -> discord.Embed: ) -> discord.Embed:
""" """
Create detailed admin view of draft status. Create detailed admin view of draft status.
@ -397,14 +464,24 @@ async def create_admin_draft_info_embed(
Args: Args:
draft_data: Current draft configuration draft_data: Current draft configuration
current_pick: Current DraftPick (optional) current_pick: Current DraftPick (optional)
sheet_url: Optional Google Sheets URL for draft tracking
Returns: Returns:
Discord embed with admin information Discord embed with admin information
""" """
embed = EmbedTemplate.info( # Use warning color if paused
title="⚙️ Draft Administration", if draft_data.paused:
description="Current draft configuration and state" embed = EmbedTemplate.create_base_embed(
) title="⚙️ Draft Administration - PAUSED",
description="Current draft configuration and state",
color=EmbedColors.WARNING
)
else:
embed = EmbedTemplate.create_base_embed(
title="⚙️ Draft Administration",
description="Current draft configuration and state",
color=EmbedColors.INFO
)
# Current pick # Current pick
embed.add_field( embed.add_field(
@ -413,11 +490,20 @@ async def create_admin_draft_info_embed(
inline=True inline=True
) )
# Timer status # Timer status (show paused prominently)
timer_emoji = "" if draft_data.timer else "⏹️" if draft_data.paused:
timer_emoji = "⏸️"
timer_text = "PAUSED"
elif draft_data.timer:
timer_emoji = ""
timer_text = "Active"
else:
timer_emoji = "⏹️"
timer_text = "Inactive"
embed.add_field( embed.add_field(
name="Timer Status", name="Timer Status",
value=f"{timer_emoji} {'Active' if draft_data.timer else 'Inactive'}", value=f"{timer_emoji} {timer_text}",
inline=True inline=True
) )
@ -428,6 +514,14 @@ async def create_admin_draft_info_embed(
inline=True inline=True
) )
# Pause status (prominent if paused)
if draft_data.paused:
embed.add_field(
name="Pause Status",
value="🚫 **PAUSED** - No picks allowed\nUse `/draft-admin resume` to allow picks",
inline=False
)
# Channels # Channels
ping_channel_value = f"<#{draft_data.ping_channel}>" if draft_data.ping_channel else "Not configured" ping_channel_value = f"<#{draft_data.ping_channel}>" if draft_data.ping_channel else "Not configured"
embed.add_field( embed.add_field(
@ -460,6 +554,124 @@ async def create_admin_draft_info_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
)
embed.set_footer(text="Use /draft-admin to modify draft settings") embed.set_footer(text="Use /draft-admin to modify draft settings")
return embed return embed
async def create_on_clock_announcement_embed(
current_pick: DraftPick,
draft_data: DraftData,
recent_picks: List[DraftPick],
roster_swar: float,
cap_limit: float,
top_roster_players: List[Player],
sheet_url: Optional[str] = None
) -> discord.Embed:
"""
Create announcement embed for when a team is on the clock.
Used to post in the ping channel when:
- Timer is enabled and pick advances
- Auto-draft completes
- Pick is skipped
Args:
current_pick: The current DraftPick (team now on the clock)
draft_data: Current draft configuration (for timer/deadline info)
recent_picks: Last 5 completed picks
roster_swar: Team's current total sWAR
cap_limit: Team's salary cap limit
top_roster_players: Top 5 most expensive players on the team's roster
sheet_url: Optional Google Sheets URL for draft tracking
Returns:
Discord embed announcing team is on the clock
"""
if not current_pick.owner:
raise ValueError("Pick must have owner")
team = current_pick.owner
# Create embed with team color if available
team_color = int(team.color, 16) if team.color else EmbedColors.PRIMARY
embed = EmbedTemplate.create_base_embed(
title=f"{team.lname} On The Clock",
description=format_pick_display(current_pick.overall),
color=team_color
)
# Set team thumbnail
if team.thumbnail:
embed.set_thumbnail(url=team.thumbnail)
# Deadline field (if timer active)
if draft_data.timer and draft_data.pick_deadline:
deadline_timestamp = int(draft_data.pick_deadline.timestamp())
embed.add_field(
name="⏱️ Deadline",
value=f"<t:{deadline_timestamp}:T> (<t:{deadline_timestamp}:R>)",
inline=True
)
# Team sWAR
embed.add_field(
name="💰 Team sWAR",
value=f"{roster_swar:.2f} / {cap_limit:.2f}",
inline=True
)
# Cap space remaining
cap_remaining = cap_limit - roster_swar
embed.add_field(
name="📊 Cap Space",
value=f"{cap_remaining:.2f}",
inline=True
)
# Last 5 picks
if recent_picks:
recent_str = ""
for pick in recent_picks[:5]:
if pick.player and pick.owner:
recent_str += f"**#{pick.overall}** {pick.owner.abbrev} - {pick.player.name}\n"
if recent_str:
embed.add_field(
name="📋 Last 5 Picks",
value=recent_str,
inline=False
)
# Top 5 most expensive players on team roster
if top_roster_players:
expensive_str = ""
for player in top_roster_players[:5]:
pos = player.pos_1 if hasattr(player, 'pos_1') and player.pos_1 else "?"
expensive_str += f"**{player.name}** ({pos}) - {player.wara:.2f}\n"
embed.add_field(
name="🌟 Top Roster sWAR",
value=expensive_str,
inline=False
)
# Draft Sheet link
if sheet_url:
embed.add_field(
name="📊 Draft Sheet",
value=f"[View Full Board]({sheet_url})",
inline=False
)
# Footer with pick info
if current_pick.is_traded:
embed.set_footer(text="📝 This pick was acquired via trade")
return embed