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:
commit
5d393f4f53
@ -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`
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,9 +183,19 @@ 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
|
||||||
|
skipped_picks = await draft_pick_service.get_skipped_picks_for_team(
|
||||||
|
config.sba_season,
|
||||||
|
team.id,
|
||||||
|
draft_data.currentpick
|
||||||
|
)
|
||||||
|
|
||||||
|
if not skipped_picks:
|
||||||
|
# No skipped picks - can't draft
|
||||||
embed = await create_pick_illegal_embed(
|
embed = await create_pick_illegal_embed(
|
||||||
"Not Your Turn",
|
"Not Your Turn",
|
||||||
f"{current_pick.owner.sname} is on the clock for {format_pick_display(current_pick.overall)}."
|
f"{current_pick.owner.sname} is on the clock for {format_pick_display(current_pick.overall)}."
|
||||||
@ -183,6 +203,13 @@ class DraftPicksCog(commands.Cog):
|
|||||||
await interaction.followup.send(embed=embed)
|
await interaction.followup.send(embed=embed)
|
||||||
return
|
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})"
|
||||||
|
)
|
||||||
|
|
||||||
# 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)
|
||||||
|
if not is_skipped_pick:
|
||||||
await draft_service.advance_pick(draft_data.id, draft_data.currentpick)
|
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."""
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
45
config.py
45
config.py
@ -1,6 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Configuration management for Discord Bot v2.0
|
Configuration management for Discord Bot v2.0
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
# Baseball position constants (static, not configurable)
|
# Baseball position constants (static, not configurable)
|
||||||
@ -84,6 +87,12 @@ class BotConfig(BaseSettings):
|
|||||||
# Google Sheets settings
|
# Google Sheets settings
|
||||||
sheets_credentials_path: str = "/app/data/major-domo-service-creds.json"
|
sheets_credentials_path: str = "/app/data/major-domo-service-creds.json"
|
||||||
|
|
||||||
|
# Draft Sheet settings (for writing picks to Google Sheets)
|
||||||
|
# Sheet IDs can be overridden via environment variables: DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc.
|
||||||
|
draft_sheet_enabled: bool = True # Feature flag - set DRAFT_SHEET_ENABLED=false to disable
|
||||||
|
draft_sheet_worksheet: str = "Ordered List" # Worksheet name to write picks to
|
||||||
|
draft_sheet_start_column: str = "D" # Column where pick data starts (D, E, F, G for 4 columns)
|
||||||
|
|
||||||
# Giphy API settings
|
# Giphy API settings
|
||||||
giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD"
|
giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD"
|
||||||
giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate"
|
giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate"
|
||||||
@ -113,6 +122,42 @@ class BotConfig(BaseSettings):
|
|||||||
"""Calculate total picks in draft (derived value)."""
|
"""Calculate total picks in draft (derived value)."""
|
||||||
return self.draft_rounds * self.draft_team_count
|
return self.draft_rounds * self.draft_team_count
|
||||||
|
|
||||||
|
def get_draft_sheet_key(self, season: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the Google Sheet ID for a given draft season.
|
||||||
|
|
||||||
|
Sheet IDs are configured via environment variables:
|
||||||
|
- DRAFT_SHEET_KEY_12 for season 12
|
||||||
|
- DRAFT_SHEET_KEY_13 for season 13
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
Returns None if no sheet is configured for the season.
|
||||||
|
"""
|
||||||
|
# Default sheet IDs (hardcoded as fallback)
|
||||||
|
default_keys = {
|
||||||
|
12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU",
|
||||||
|
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
|
||||||
|
|||||||
@ -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,8 +33,8 @@ 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:
|
||||||
@ -42,6 +43,16 @@ class DraftData(SBABaseModel):
|
|||||||
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)"
|
||||||
@ -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."""
|
||||||
|
|||||||
@ -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
|
||||||
@ -18,17 +24,75 @@ class DraftPick(SBABaseModel):
|
|||||||
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:
|
||||||
"""Check if this pick has been traded."""
|
"""Check if this pick has been traded."""
|
||||||
|
|||||||
@ -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':
|
||||||
|
|||||||
@ -377,6 +377,65 @@ class SheetsService:
|
|||||||
async def read_box_score(scorecard: pygsheets.Spreadsheet) -> Dict[str, List[int]]
|
async def read_box_score(scorecard: pygsheets.Spreadsheet) -> Dict[str, List[int]]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### DraftSheetService Key Methods (NEW - December 2025)
|
||||||
|
```python
|
||||||
|
class DraftSheetService(SheetsService):
|
||||||
|
"""
|
||||||
|
Service for writing draft picks to Google Sheets.
|
||||||
|
Extends SheetsService to reuse authentication and async patterns.
|
||||||
|
"""
|
||||||
|
async def write_pick(
|
||||||
|
season: int,
|
||||||
|
overall: int,
|
||||||
|
orig_owner_abbrev: str,
|
||||||
|
owner_abbrev: str,
|
||||||
|
player_name: str,
|
||||||
|
swar: float
|
||||||
|
) -> bool
|
||||||
|
"""Write a single pick to the draft sheet. Returns True on success."""
|
||||||
|
|
||||||
|
async def write_picks_batch(
|
||||||
|
season: int,
|
||||||
|
picks: List[Tuple[int, str, str, str, float]]
|
||||||
|
) -> Tuple[int, int]
|
||||||
|
"""Batch write picks for resync operations. Returns (success_count, failure_count)."""
|
||||||
|
|
||||||
|
async def clear_picks_range(
|
||||||
|
season: int,
|
||||||
|
start_overall: int = 1,
|
||||||
|
end_overall: int = 512
|
||||||
|
) -> bool
|
||||||
|
"""Clear a range of picks from the sheet before resync."""
|
||||||
|
|
||||||
|
def get_sheet_url(season: int) -> Optional[str]
|
||||||
|
"""Get the draft sheet URL for display in embeds."""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration Points:**
|
||||||
|
- `commands/draft/picks.py` - Writes pick to sheet after successful draft selection
|
||||||
|
- `tasks/draft_monitor.py` - Writes pick to sheet after auto-draft
|
||||||
|
- `commands/draft/admin.py` - `/draft-admin resync-sheet` for bulk recovery
|
||||||
|
|
||||||
|
**Fire-and-Forget Pattern:**
|
||||||
|
Draft sheet writes are non-critical (database is source of truth). On failure:
|
||||||
|
1. Log the error
|
||||||
|
2. Notify ping channel with warning message
|
||||||
|
3. Suggest `/draft-admin resync-sheet` for recovery
|
||||||
|
4. Never block the draft pick itself
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```python
|
||||||
|
# config.py settings
|
||||||
|
draft_sheet_enabled: bool = True # Feature flag
|
||||||
|
draft_sheet_worksheet: str = "Ordered List" # Worksheet name
|
||||||
|
draft_sheet_start_column: str = "D" # Starting column
|
||||||
|
|
||||||
|
# Season-specific sheet IDs via environment variables
|
||||||
|
# DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc.
|
||||||
|
config.get_draft_sheet_key(season) # Returns sheet ID or None
|
||||||
|
config.get_draft_sheet_url(season) # Returns full URL or None
|
||||||
|
```
|
||||||
|
|
||||||
**Transaction Rollback Pattern:**
|
**Transaction Rollback Pattern:**
|
||||||
The game submission services implement a 3-state transaction rollback pattern:
|
The game submission services implement a 3-state transaction rollback pattern:
|
||||||
1. **PLAYS_POSTED**: Plays submitted → Rollback: Delete plays
|
1. **PLAYS_POSTED**: Plays submitted → Rollback: Delete plays
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from .player_service import PlayerService, player_service
|
|||||||
from .league_service import LeagueService, league_service
|
from .league_service import LeagueService, league_service
|
||||||
from .schedule_service import ScheduleService, schedule_service
|
from .schedule_service import ScheduleService, schedule_service
|
||||||
from .giphy_service import GiphyService
|
from .giphy_service import GiphyService
|
||||||
|
from .draft_sheet_service import DraftSheetService, get_draft_sheet_service
|
||||||
|
|
||||||
# Wire services together for dependency injection
|
# Wire services together for dependency injection
|
||||||
player_service._team_service = team_service
|
player_service._team_service = team_service
|
||||||
@ -21,5 +22,6 @@ __all__ = [
|
|||||||
'PlayerService', 'player_service',
|
'PlayerService', 'player_service',
|
||||||
'LeagueService', 'league_service',
|
'LeagueService', 'league_service',
|
||||||
'ScheduleService', 'schedule_service',
|
'ScheduleService', 'schedule_service',
|
||||||
'GiphyService', 'giphy_service'
|
'GiphyService', 'giphy_service',
|
||||||
|
'DraftSheetService', 'get_draft_sheet_service'
|
||||||
]
|
]
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
312
services/draft_sheet_service.py
Normal file
312
services/draft_sheet_service.py
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
"""
|
||||||
|
Draft Sheet Service
|
||||||
|
|
||||||
|
Handles writing draft picks to Google Sheets for public tracking.
|
||||||
|
Extends SheetsService to reuse authentication and async patterns.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from config import get_config
|
||||||
|
from exceptions import SheetsException
|
||||||
|
from services.sheets_service import SheetsService
|
||||||
|
from utils.logging import get_contextual_logger
|
||||||
|
|
||||||
|
|
||||||
|
class DraftSheetService(SheetsService):
|
||||||
|
"""Service for writing draft picks to Google Sheets."""
|
||||||
|
|
||||||
|
def __init__(self, credentials_path: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize draft sheet service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials_path: Path to service account credentials JSON
|
||||||
|
If None, will use path from config
|
||||||
|
"""
|
||||||
|
super().__init__(credentials_path)
|
||||||
|
self.logger = get_contextual_logger(f'{__name__}.DraftSheetService')
|
||||||
|
self._config = get_config()
|
||||||
|
|
||||||
|
async def write_pick(
|
||||||
|
self,
|
||||||
|
season: int,
|
||||||
|
overall: int,
|
||||||
|
orig_owner_abbrev: str,
|
||||||
|
owner_abbrev: str,
|
||||||
|
player_name: str,
|
||||||
|
swar: float
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Write a single draft pick to the season's draft sheet.
|
||||||
|
|
||||||
|
Data is written to columns D-G (4 columns):
|
||||||
|
- D: Original owner abbreviation (for traded picks)
|
||||||
|
- E: Current owner abbreviation
|
||||||
|
- F: Player name
|
||||||
|
- G: Player sWAR value
|
||||||
|
|
||||||
|
Row number is calculated as: overall + 1 (pick 1 goes to row 2).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
season: Draft season number
|
||||||
|
overall: Overall pick number (1-512)
|
||||||
|
orig_owner_abbrev: Original owner team abbreviation
|
||||||
|
owner_abbrev: Current owner team abbreviation
|
||||||
|
player_name: Name of the drafted player
|
||||||
|
swar: Player's sWAR (WAR Above Replacement) value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if write succeeded, False otherwise
|
||||||
|
"""
|
||||||
|
if not self._config.draft_sheet_enabled:
|
||||||
|
self.logger.debug("Draft sheet writes are disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
sheet_key = self._config.get_draft_sheet_key(season)
|
||||||
|
if not sheet_key:
|
||||||
|
self.logger.warning(f"No draft sheet configured for season {season}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
# Get pygsheets client
|
||||||
|
sheets = await loop.run_in_executor(None, self._get_client)
|
||||||
|
|
||||||
|
# Open the draft sheet by key
|
||||||
|
spreadsheet = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
sheets.open_by_key,
|
||||||
|
sheet_key
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the worksheet
|
||||||
|
worksheet = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
spreadsheet.worksheet_by_title,
|
||||||
|
self._config.draft_sheet_worksheet
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepare pick data (4 columns: orig_owner, owner, player, swar)
|
||||||
|
pick_data = [[orig_owner_abbrev, owner_abbrev, player_name, swar]]
|
||||||
|
|
||||||
|
# Calculate row (overall + 1 to leave row 1 for headers)
|
||||||
|
row = overall + 1
|
||||||
|
start_column = self._config.draft_sheet_start_column
|
||||||
|
cell_range = f'{start_column}{row}'
|
||||||
|
|
||||||
|
# Write the pick data
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: worksheet.update_values(crange=cell_range, values=pick_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Wrote pick {overall} to draft sheet",
|
||||||
|
season=season,
|
||||||
|
overall=overall,
|
||||||
|
player=player_name,
|
||||||
|
owner=owner_abbrev
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Failed to write pick to draft sheet: {e}",
|
||||||
|
season=season,
|
||||||
|
overall=overall,
|
||||||
|
player=player_name
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def write_picks_batch(
|
||||||
|
self,
|
||||||
|
season: int,
|
||||||
|
picks: List[Tuple[int, str, str, str, float]]
|
||||||
|
) -> Tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Write multiple draft picks to the sheet in a single batch operation.
|
||||||
|
|
||||||
|
Used for resync operations to repopulate the entire sheet from database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
season: Draft season number
|
||||||
|
picks: List of tuples (overall, orig_owner_abbrev, owner_abbrev, player_name, swar)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success_count, failure_count)
|
||||||
|
"""
|
||||||
|
if not self._config.draft_sheet_enabled:
|
||||||
|
self.logger.debug("Draft sheet writes are disabled")
|
||||||
|
return (0, len(picks))
|
||||||
|
|
||||||
|
sheet_key = self._config.get_draft_sheet_key(season)
|
||||||
|
if not sheet_key:
|
||||||
|
self.logger.warning(f"No draft sheet configured for season {season}")
|
||||||
|
return (0, len(picks))
|
||||||
|
|
||||||
|
if not picks:
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
# Get pygsheets client
|
||||||
|
sheets = await loop.run_in_executor(None, self._get_client)
|
||||||
|
|
||||||
|
# Open the draft sheet by key
|
||||||
|
spreadsheet = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
sheets.open_by_key,
|
||||||
|
sheet_key
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the worksheet
|
||||||
|
worksheet = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
spreadsheet.worksheet_by_title,
|
||||||
|
self._config.draft_sheet_worksheet
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort picks by overall to write in order
|
||||||
|
sorted_picks = sorted(picks, key=lambda p: p[0])
|
||||||
|
|
||||||
|
# Build batch data - each pick goes to its calculated row
|
||||||
|
# We'll write one row at a time to handle non-contiguous picks
|
||||||
|
success_count = 0
|
||||||
|
failure_count = 0
|
||||||
|
|
||||||
|
for overall, orig_owner, owner, player_name, swar in sorted_picks:
|
||||||
|
try:
|
||||||
|
pick_data = [[orig_owner, owner, player_name, swar]]
|
||||||
|
row = overall + 1
|
||||||
|
start_column = self._config.draft_sheet_start_column
|
||||||
|
cell_range = f'{start_column}{row}'
|
||||||
|
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda cr=cell_range, pd=pick_data: worksheet.update_values(
|
||||||
|
crange=cr, values=pd
|
||||||
|
)
|
||||||
|
)
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to write pick {overall}: {e}")
|
||||||
|
failure_count += 1
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Batch write complete: {success_count} succeeded, {failure_count} failed",
|
||||||
|
season=season,
|
||||||
|
total_picks=len(picks)
|
||||||
|
)
|
||||||
|
return (success_count, failure_count)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to initialize batch write: {e}", season=season)
|
||||||
|
return (0, len(picks))
|
||||||
|
|
||||||
|
async def clear_picks_range(
|
||||||
|
self,
|
||||||
|
season: int,
|
||||||
|
start_overall: int = 1,
|
||||||
|
end_overall: int = 512
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Clear a range of picks from the draft sheet.
|
||||||
|
|
||||||
|
Used before resync to clear existing data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
season: Draft season number
|
||||||
|
start_overall: First pick to clear (default: 1)
|
||||||
|
end_overall: Last pick to clear (default: 512 for 32 rounds * 16 teams)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if clear succeeded, False otherwise
|
||||||
|
"""
|
||||||
|
if not self._config.draft_sheet_enabled:
|
||||||
|
self.logger.debug("Draft sheet writes are disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
sheet_key = self._config.get_draft_sheet_key(season)
|
||||||
|
if not sheet_key:
|
||||||
|
self.logger.warning(f"No draft sheet configured for season {season}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
# Get pygsheets client
|
||||||
|
sheets = await loop.run_in_executor(None, self._get_client)
|
||||||
|
|
||||||
|
# Open the draft sheet by key
|
||||||
|
spreadsheet = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
sheets.open_by_key,
|
||||||
|
sheet_key
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the worksheet
|
||||||
|
worksheet = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
spreadsheet.worksheet_by_title,
|
||||||
|
self._config.draft_sheet_worksheet
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate range (4 columns: D through G)
|
||||||
|
start_row = start_overall + 1
|
||||||
|
end_row = end_overall + 1
|
||||||
|
start_column = self._config.draft_sheet_start_column
|
||||||
|
|
||||||
|
# Convert start column letter to end column (D -> G for 4 columns)
|
||||||
|
end_column = chr(ord(start_column) + 3)
|
||||||
|
|
||||||
|
cell_range = f'{start_column}{start_row}:{end_column}{end_row}'
|
||||||
|
|
||||||
|
# Clear the range by setting empty values
|
||||||
|
# We create a 2D array of empty strings
|
||||||
|
num_rows = end_row - start_row + 1
|
||||||
|
empty_data = [['', '', '', ''] for _ in range(num_rows)]
|
||||||
|
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: worksheet.update_values(
|
||||||
|
crange=f'{start_column}{start_row}',
|
||||||
|
values=empty_data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Cleared picks {start_overall}-{end_overall} from draft sheet",
|
||||||
|
season=season
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to clear draft sheet: {e}", season=season)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_sheet_url(self, season: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the full Google Sheets URL for a given draft season.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
season: Draft season number
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full URL to the draft sheet, or None if not configured
|
||||||
|
"""
|
||||||
|
return self._config.get_draft_sheet_url(season)
|
||||||
|
|
||||||
|
|
||||||
|
# Global service instance - lazily initialized
|
||||||
|
_draft_sheet_service: Optional[DraftSheetService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_draft_sheet_service() -> DraftSheetService:
|
||||||
|
"""Get the global draft sheet service instance."""
|
||||||
|
global _draft_sheet_service
|
||||||
|
if _draft_sheet_service is None:
|
||||||
|
_draft_sheet_service = DraftSheetService()
|
||||||
|
return _draft_sheet_service
|
||||||
@ -254,6 +254,26 @@ class PlayerService(BaseService[Player]):
|
|||||||
"""
|
"""
|
||||||
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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
349
tests/test_services_draft_sheet.py
Normal file
349
tests/test_services_draft_sheet.py
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
"""
|
||||||
|
Tests for DraftSheetService
|
||||||
|
|
||||||
|
Tests the Google Sheets integration for draft pick tracking.
|
||||||
|
Uses mocked pygsheets to avoid actual API calls.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from typing import Tuple, List
|
||||||
|
|
||||||
|
from services.draft_sheet_service import DraftSheetService, get_draft_sheet_service
|
||||||
|
|
||||||
|
|
||||||
|
class TestDraftSheetService:
|
||||||
|
"""
|
||||||
|
Test suite for DraftSheetService.
|
||||||
|
|
||||||
|
Tests write_pick(), write_picks_batch(), clear_picks_range(), and get_sheet_url().
|
||||||
|
All tests mock pygsheets to avoid actual Google Sheets API calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config(self):
|
||||||
|
"""
|
||||||
|
Create a mock config with draft sheet settings.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- draft_sheet_enabled: True
|
||||||
|
- sba_season: 12
|
||||||
|
- draft_sheet_worksheet: "Ordered List"
|
||||||
|
- draft_sheet_start_column: "D"
|
||||||
|
- draft_total_picks: 512
|
||||||
|
"""
|
||||||
|
config = MagicMock()
|
||||||
|
config.draft_sheet_enabled = True
|
||||||
|
config.sba_season = 12
|
||||||
|
config.draft_sheet_worksheet = "Ordered List"
|
||||||
|
config.draft_sheet_start_column = "D"
|
||||||
|
config.draft_total_picks = 512
|
||||||
|
config.sheets_credentials_path = "/app/data/test-creds.json"
|
||||||
|
config.get_draft_sheet_key = MagicMock(return_value="test-sheet-key-123")
|
||||||
|
config.get_draft_sheet_url = MagicMock(
|
||||||
|
return_value="https://docs.google.com/spreadsheets/d/test-sheet-key-123"
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_pygsheets(self):
|
||||||
|
"""
|
||||||
|
Create mock pygsheets client, spreadsheet, and worksheet.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- sheets_client: Mock pygsheets client
|
||||||
|
- spreadsheet: Mock spreadsheet
|
||||||
|
- worksheet: Mock worksheet with update_values method
|
||||||
|
"""
|
||||||
|
worksheet = MagicMock()
|
||||||
|
worksheet.update_values = MagicMock()
|
||||||
|
|
||||||
|
spreadsheet = MagicMock()
|
||||||
|
spreadsheet.worksheet_by_title = MagicMock(return_value=worksheet)
|
||||||
|
|
||||||
|
sheets_client = MagicMock()
|
||||||
|
sheets_client.open_by_key = MagicMock(return_value=spreadsheet)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'client': sheets_client,
|
||||||
|
'spreadsheet': spreadsheet,
|
||||||
|
'worksheet': worksheet
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(self, mock_config, mock_pygsheets):
|
||||||
|
"""
|
||||||
|
Create DraftSheetService instance with mocked dependencies.
|
||||||
|
|
||||||
|
The service is set up with:
|
||||||
|
- Mocked config
|
||||||
|
- Mocked pygsheets client (via _get_client override)
|
||||||
|
"""
|
||||||
|
with patch('services.draft_sheet_service.get_config', return_value=mock_config):
|
||||||
|
service = DraftSheetService()
|
||||||
|
service._config = mock_config
|
||||||
|
service._sheets_client = mock_pygsheets['client']
|
||||||
|
return service
|
||||||
|
|
||||||
|
# ==================== write_pick() Tests ====================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_pick_success(self, service, mock_pygsheets):
|
||||||
|
"""
|
||||||
|
Test successful write of a single draft pick to the sheet.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Correct cell range is calculated (D + overall + 1)
|
||||||
|
- Correct data is written (4 columns)
|
||||||
|
- Returns True on success
|
||||||
|
"""
|
||||||
|
result = await service.write_pick(
|
||||||
|
season=12,
|
||||||
|
overall=1,
|
||||||
|
orig_owner_abbrev="HAM",
|
||||||
|
owner_abbrev="HAM",
|
||||||
|
player_name="Mike Trout",
|
||||||
|
swar=8.5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
# Verify worksheet was accessed
|
||||||
|
mock_pygsheets['spreadsheet'].worksheet_by_title.assert_called_with("Ordered List")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_pick_disabled(self, service, mock_config):
|
||||||
|
"""
|
||||||
|
Test that write_pick returns False when feature is disabled.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Returns False when draft_sheet_enabled is False
|
||||||
|
- No API calls are made
|
||||||
|
"""
|
||||||
|
mock_config.draft_sheet_enabled = False
|
||||||
|
|
||||||
|
result = await service.write_pick(
|
||||||
|
season=12,
|
||||||
|
overall=1,
|
||||||
|
orig_owner_abbrev="HAM",
|
||||||
|
owner_abbrev="HAM",
|
||||||
|
player_name="Mike Trout",
|
||||||
|
swar=8.5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_pick_no_sheet_configured(self, service, mock_config):
|
||||||
|
"""
|
||||||
|
Test that write_pick returns False when no sheet is configured for season.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Returns False when get_draft_sheet_key returns None
|
||||||
|
- No API calls are made
|
||||||
|
"""
|
||||||
|
mock_config.get_draft_sheet_key = MagicMock(return_value=None)
|
||||||
|
|
||||||
|
result = await service.write_pick(
|
||||||
|
season=13, # Season 13 has no configured sheet
|
||||||
|
overall=1,
|
||||||
|
orig_owner_abbrev="HAM",
|
||||||
|
owner_abbrev="HAM",
|
||||||
|
player_name="Mike Trout",
|
||||||
|
swar=8.5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_pick_api_error(self, service, mock_pygsheets):
|
||||||
|
"""
|
||||||
|
Test that write_pick returns False and logs error on API failure.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Returns False on exception
|
||||||
|
- Exception is caught and logged (not raised)
|
||||||
|
"""
|
||||||
|
mock_pygsheets['spreadsheet'].worksheet_by_title.side_effect = Exception("API Error")
|
||||||
|
|
||||||
|
result = await service.write_pick(
|
||||||
|
season=12,
|
||||||
|
overall=1,
|
||||||
|
orig_owner_abbrev="HAM",
|
||||||
|
owner_abbrev="HAM",
|
||||||
|
player_name="Mike Trout",
|
||||||
|
swar=8.5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
# ==================== write_picks_batch() Tests ====================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_picks_batch_success(self, service, mock_pygsheets):
|
||||||
|
"""
|
||||||
|
Test successful batch write of multiple picks.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- All picks are written
|
||||||
|
- Returns correct success/failure counts
|
||||||
|
"""
|
||||||
|
picks = [
|
||||||
|
(1, "HAM", "HAM", "Player 1", 2.5),
|
||||||
|
(2, "NYY", "NYY", "Player 2", 3.0),
|
||||||
|
(3, "BOS", "BOS", "Player 3", 1.5),
|
||||||
|
]
|
||||||
|
|
||||||
|
success_count, failure_count = await service.write_picks_batch(
|
||||||
|
season=12,
|
||||||
|
picks=picks
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success_count == 3
|
||||||
|
assert failure_count == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_picks_batch_empty_list(self, service):
|
||||||
|
"""
|
||||||
|
Test batch write with empty picks list.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Returns (0, 0) for empty list
|
||||||
|
- No API calls are made
|
||||||
|
"""
|
||||||
|
success_count, failure_count = await service.write_picks_batch(
|
||||||
|
season=12,
|
||||||
|
picks=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success_count == 0
|
||||||
|
assert failure_count == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_picks_batch_disabled(self, service, mock_config):
|
||||||
|
"""
|
||||||
|
Test batch write when feature is disabled.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Returns (0, total_picks) when disabled
|
||||||
|
"""
|
||||||
|
mock_config.draft_sheet_enabled = False
|
||||||
|
picks = [
|
||||||
|
(1, "HAM", "HAM", "Player 1", 2.5),
|
||||||
|
(2, "NYY", "NYY", "Player 2", 3.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
success_count, failure_count = await service.write_picks_batch(
|
||||||
|
season=12,
|
||||||
|
picks=picks
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success_count == 0
|
||||||
|
assert failure_count == 2
|
||||||
|
|
||||||
|
# ==================== clear_picks_range() Tests ====================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_picks_range_success(self, service, mock_pygsheets):
|
||||||
|
"""
|
||||||
|
Test successful clearing of picks range.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Returns True on success
|
||||||
|
- Correct range is cleared
|
||||||
|
"""
|
||||||
|
result = await service.clear_picks_range(
|
||||||
|
season=12,
|
||||||
|
start_overall=1,
|
||||||
|
end_overall=512
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_picks_range_disabled(self, service, mock_config):
|
||||||
|
"""
|
||||||
|
Test clearing when feature is disabled.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Returns False when disabled
|
||||||
|
"""
|
||||||
|
mock_config.draft_sheet_enabled = False
|
||||||
|
|
||||||
|
result = await service.clear_picks_range(
|
||||||
|
season=12,
|
||||||
|
start_overall=1,
|
||||||
|
end_overall=512
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
# ==================== get_sheet_url() Tests ====================
|
||||||
|
|
||||||
|
def test_get_sheet_url_configured(self, service, mock_config):
|
||||||
|
"""
|
||||||
|
Test get_sheet_url returns URL when configured.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Returns correct URL format
|
||||||
|
"""
|
||||||
|
url = service.get_sheet_url(season=12)
|
||||||
|
|
||||||
|
assert url == "https://docs.google.com/spreadsheets/d/test-sheet-key-123"
|
||||||
|
|
||||||
|
def test_get_sheet_url_not_configured(self, service, mock_config):
|
||||||
|
"""
|
||||||
|
Test get_sheet_url returns None when not configured.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Returns None for unconfigured season
|
||||||
|
"""
|
||||||
|
mock_config.get_draft_sheet_url = MagicMock(return_value=None)
|
||||||
|
|
||||||
|
url = service.get_sheet_url(season=99)
|
||||||
|
|
||||||
|
assert url is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalServiceInstance:
|
||||||
|
"""
|
||||||
|
Test suite for the global service instance pattern.
|
||||||
|
|
||||||
|
Tests get_draft_sheet_service() lazy initialization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_get_draft_sheet_service_returns_instance(self):
|
||||||
|
"""
|
||||||
|
Test that get_draft_sheet_service returns a DraftSheetService instance.
|
||||||
|
|
||||||
|
Note: This creates a real service instance but won't make API calls
|
||||||
|
without being used.
|
||||||
|
"""
|
||||||
|
with patch('services.draft_sheet_service.get_config') as mock_config:
|
||||||
|
mock_config.return_value.sheets_credentials_path = "/test/path.json"
|
||||||
|
mock_config.return_value.draft_sheet_enabled = True
|
||||||
|
|
||||||
|
# Reset global instance
|
||||||
|
import services.draft_sheet_service as service_module
|
||||||
|
service_module._draft_sheet_service = None
|
||||||
|
|
||||||
|
service = get_draft_sheet_service()
|
||||||
|
|
||||||
|
assert isinstance(service, DraftSheetService)
|
||||||
|
|
||||||
|
def test_get_draft_sheet_service_returns_same_instance(self):
|
||||||
|
"""
|
||||||
|
Test that get_draft_sheet_service returns the same instance on subsequent calls.
|
||||||
|
|
||||||
|
Verifies singleton pattern for global service.
|
||||||
|
"""
|
||||||
|
with patch('services.draft_sheet_service.get_config') as mock_config:
|
||||||
|
mock_config.return_value.sheets_credentials_path = "/test/path.json"
|
||||||
|
mock_config.return_value.draft_sheet_enabled = True
|
||||||
|
|
||||||
|
# Reset global instance
|
||||||
|
import services.draft_sheet_service as service_module
|
||||||
|
service_module._draft_sheet_service = None
|
||||||
|
|
||||||
|
service1 = get_draft_sheet_service()
|
||||||
|
service2 = get_draft_sheet_service()
|
||||||
|
|
||||||
|
assert service1 is service2
|
||||||
534
tests/test_utils_draft_helpers.py
Normal file
534
tests/test_utils_draft_helpers.py
Normal 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
421
tests/test_utils_helpers.py
Normal 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
|
||||||
@ -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
59
utils/helpers.py
Normal 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)
|
||||||
@ -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,10 +130,18 @@ 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
|
||||||
"""
|
"""
|
||||||
|
# Use warning color if paused
|
||||||
|
if draft_data.paused:
|
||||||
|
embed = EmbedTemplate.warning(
|
||||||
|
title="Draft Status - PAUSED",
|
||||||
|
description=f"Currently on {format_pick_display(draft_data.currentpick)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
embed = EmbedTemplate.info(
|
embed = EmbedTemplate.info(
|
||||||
title="Draft Status",
|
title="Draft Status",
|
||||||
description=f"Currently on {format_pick_display(draft_data.currentpick)}"
|
description=f"Currently on {format_pick_display(draft_data.currentpick)}"
|
||||||
@ -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,13 +464,23 @@ 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
|
||||||
|
if draft_data.paused:
|
||||||
|
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",
|
title="⚙️ Draft Administration",
|
||||||
description="Current draft configuration and state"
|
description="Current draft configuration and state",
|
||||||
|
color=EmbedColors.INFO
|
||||||
)
|
)
|
||||||
|
|
||||||
# Current pick
|
# Current pick
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user