diff --git a/commands/draft/CLAUDE.md b/commands/draft/CLAUDE.md index 1313318..162a442 100644 --- a/commands/draft/CLAUDE.md +++ b/commands/draft/CLAUDE.md @@ -49,9 +49,29 @@ Draft commands are only available in the offseason. - `player_service.get_players_by_name()` - `player_service.update_player_team()` - `league_service.get_current_state()` (for period check) + - `draft_sheet_service.write_pick()` (Google Sheets integration) ## Key Features +### 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 - **Purpose**: Prevent concurrent draft picks that could cause race conditions - **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) 6. **Cap Space**: Validate 32 sWAR limit won't be exceeded 7. **Execution**: Update pick, update player team, advance draft -8. **Announcements**: Post success message and player card +8. **Sheet Write**: Write pick to Google Sheets (fire-and-forget) +9. **Announcements**: Post success message and player card ### FA Player Autocomplete The autocomplete function filters to FA players only: @@ -122,6 +143,93 @@ async def validate_cap_space(roster: dict, new_player_wara: float): return projected_total <= 32.00001, projected_total ``` +## Google Sheets Integration + +### Overview +Draft picks are automatically written to a shared Google Sheet for easy tracking. This feature: +- Writes pick data to configured sheet after each successful pick +- Uses **fire-and-forget** pattern (non-blocking, doesn't fail the pick) +- Supports manual resync via `/draft-admin resync-sheet` +- Shows sheet link in `/draft-status` embed + +### Sheet Structure +Each pick writes 4 columns starting at column D: +| Column | Content | +|--------|---------| +| D | Original owner abbreviation | +| E | Current owner abbreviation | +| F | Player name | +| G | Player sWAR | + +Row calculation: `overall + 1` (pick 1 β†’ row 2, leaving row 1 for headers) + +### Fire-and-Forget Pattern +```python +# After successful pick execution +try: + sheet_success = await draft_sheet_service.write_pick( + season=config.sba_season, + overall=pick.overall, + orig_owner_abbrev=original_owner.abbrev, + owner_abbrev=team.abbrev, + player_name=player.name, + swar=player.wara + ) + if not sheet_success: + self.logger.warning(f"Draft sheet write failed for pick #{pick.overall}") + # Post notification to ping channel +except Exception as e: + self.logger.error(f"Draft sheet write error: {e}") + # Non-critical - don't fail the draft pick +``` + +### Configuration +Environment variables (optional, defaults in config): +- `DRAFT_SHEET_KEY_12` - Sheet ID for season 12 +- `DRAFT_SHEET_KEY_13` - Sheet ID for season 13 +- `DRAFT_SHEET_ENABLED` - Feature flag (default: True) + +Config file defaults in `config.py`: +```python +draft_sheet_keys: dict[int, str] = { + 12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU", + # Add new seasons as needed +} +draft_sheet_worksheet: str = "Ordered List" +draft_sheet_start_column: str = "D" +draft_sheet_enabled: bool = True +``` + +### `/draft-admin resync-sheet` Command +Bulk resync all picks from database to sheet: +1. Fetches all picks for current season with player data +2. Clears existing sheet data (columns D-G, rows 2+) +3. Batch writes all completed picks +4. Reports success/failure count + +Use cases: +- Sheet corruption recovery +- Credential issues during draft +- Manual corrections needed + +### `/draft-status` Sheet Link +The draft status embed includes a clickable link to the sheet: +```python +sheet_url = config.get_draft_sheet_url(config.sba_season) +embed = await create_draft_status_embed(draft_data, current_pick, lock_status, sheet_url) +``` + +### Service Dependencies +- `services.draft_sheet_service` - Google Sheets operations +- `config.get_draft_sheet_key()` - Sheet ID lookup by season +- `config.get_draft_sheet_url()` - Sheet URL generation + +### Failure Handling +- Sheet write failures don't block draft picks +- Failures logged with warning level +- Optional: Post failure notice to ping channel +- Admins can use resync-sheet for recovery + ## Architecture Notes ### Command Pattern @@ -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 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 -### `/draft-admin` (Pending Implementation) +### `/draft-admin` (Administrator Only) Admin controls: -- Timer on/off -- Set current pick -- Configure channels -- Wipe picks -- Clear stale locks -- Set keepers +- `/draft-admin timer` - Enable/disable timer (auto-starts monitor task) +- `/draft-admin set-pick` - Set current pick (auto-starts monitor if timer active) +- `/draft-admin channels` - Configure ping/result channels +- `/draft-admin wipe` - Clear all picks for season +- `/draft-admin info` - View detailed draft configuration +- `/draft-admin resync-sheet` - Resync all picks to Google Sheet -### `/draft-list` (Pending Implementation) -Manage auto-draft queue: -- View current list -- Add players -- Remove players -- Reorder players -- Clear list +### `/draft-list` +View auto-draft queue for your team -### `/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 +### `/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 - `config.get_config()` - `services.draft_service` - `services.draft_pick_service` +- `services.draft_sheet_service` (Google Sheets integration) - `services.player_service` - `services.team_service` (with caching) - `utils.decorators.logged_command` @@ -288,6 +409,6 @@ Test scenarios: --- -**Last Updated:** October 2025 -**Status:** Core `/draft` command implemented and tested -**Next:** Implement `/draft-status`, `/draft-admin`, `/draft-list` commands +**Last Updated:** December 2025 +**Status:** All draft commands implemented and tested +**Recent:** Google Sheets integration for automatic pick tracking, `/draft-admin resync-sheet` command, sheet link in `/draft-status` diff --git a/commands/draft/__init__.py b/commands/draft/__init__.py index 746f77a..a7086ee 100644 --- a/commands/draft/__init__.py +++ b/commands/draft/__init__.py @@ -56,7 +56,7 @@ async def setup_draft(bot: commands.Bot): # Load draft admin group (app_commands.Group pattern) try: - bot.tree.add_command(DraftAdminGroup()) + bot.tree.add_command(DraftAdminGroup(bot)) logger.info("βœ… Loaded DraftAdminGroup") successful += 1 except Exception as e: diff --git a/commands/draft/admin.py b/commands/draft/admin.py index bb87170..01c41d8 100644 --- a/commands/draft/admin.py +++ b/commands/draft/admin.py @@ -12,6 +12,7 @@ from discord.ext import commands from config import get_config from services.draft_service import draft_service from services.draft_pick_service import draft_pick_service +from services.draft_sheet_service import get_draft_sheet_service from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.permissions import league_admin_only @@ -22,13 +23,35 @@ from views.embeds import EmbedTemplate class DraftAdminGroup(app_commands.Group): """Draft administration command group.""" - def __init__(self): + def __init__(self, bot: commands.Bot): super().__init__( name="draft-admin", description="Admin commands for draft management" ) + self.bot = bot 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") @league_admin_only() @logged_command("/draft-admin info") @@ -53,8 +76,11 @@ class DraftAdminGroup(app_commands.Group): draft_data.currentpick ) + # Get sheet URL + sheet_url = config.get_draft_sheet_url(config.sba_season) + # 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) @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) return + # Start draft monitor task if timer is enabled + monitor_status = "" + if enabled: + monitor_status = self._ensure_monitor_running() + # Success message status = "enabled" if enabled else "disabled" description = f"Draft timer has been **{status}**." - if enabled and minutes: - description += f"\n\nPick duration: **{minutes} minutes**" - elif enabled: - description += f"\n\nPick duration: **{updated.pick_minutes} minutes**" + if enabled: + # Show pick duration + pick_mins = minutes if minutes else updated.pick_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:** ()" + + description += monitor_status embed = EmbedTemplate.success("Timer Updated", description) await interaction.followup.send(embed=embed) @@ -173,6 +214,16 @@ class DraftAdminGroup(app_commands.Group): if pick.owner: 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 " + # 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) await interaction.followup.send(embed=embed) @@ -288,7 +339,226 @@ class DraftAdminGroup(app_commands.Group): embed = EmbedTemplate.success("Deadline Reset", description) 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 " + + # 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): """Setup function for loading the draft admin commands.""" - bot.tree.add_command(DraftAdminGroup()) + bot.tree.add_command(DraftAdminGroup(bot)) diff --git a/commands/draft/board.py b/commands/draft/board.py index f89421d..6d5386f 100644 --- a/commands/draft/board.py +++ b/commands/draft/board.py @@ -71,8 +71,11 @@ class DraftBoardCommands(commands.Cog): await interaction.followup.send(embed=embed, ephemeral=True) return + # Get sheet URL + sheet_url = config.get_draft_sheet_url(config.sba_season) + # 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) diff --git a/commands/draft/list.py b/commands/draft/list.py index 3a70110..42cc376 100644 --- a/commands/draft/list.py +++ b/commands/draft/list.py @@ -266,9 +266,17 @@ class DraftListCommands(commands.Cog): await interaction.followup.send(embed=embed, ephemeral=True) return - # Success message - description = f"Removed **{player_obj.name}** from your draft queue." - embed = EmbedTemplate.success("Player Removed", description) + # Get updated list + updated_list = await draft_list_service.get_team_list( + 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) @discord.app_commands.command( @@ -329,6 +337,7 @@ class DraftListCommands(commands.Cog): # Success message description = f"Cleared **{len(current_list)} players** from your draft queue." embed = EmbedTemplate.success("Queue Cleared", description) + embed.set_footer(text="Use /draft-list-add to build your queue") await interaction.followup.send(embed=embed) diff --git a/commands/draft/picks.py b/commands/draft/picks.py index 837d272..0463b20 100644 --- a/commands/draft/picks.py +++ b/commands/draft/picks.py @@ -13,6 +13,7 @@ from discord.ext import commands from config import get_config from services.draft_service import draft_service from services.draft_pick_service import draft_pick_service +from services.draft_sheet_service import get_draft_sheet_service from services.player_service import player_service from services.team_service import team_service from utils.logging import get_contextual_logger @@ -159,6 +160,15 @@ class DraftPicksCog(commands.Cog): await interaction.followup.send(embed=embed) 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 current_pick = await draft_pick_service.get_pick( config.sba_season, @@ -173,15 +183,32 @@ class DraftPicksCog(commands.Cog): await interaction.followup.send(embed=embed) 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: - # TODO: Check for skipped picks - embed = await create_pick_illegal_embed( - "Not Your Turn", - f"{current_pick.owner.sname} is on the clock for {format_pick_display(current_pick.overall)}." + # 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( + "Not Your Turn", + f"{current_pick.owner.sname} is on the clock for {format_pick_display(current_pick.overall)}." + ) + await interaction.followup.send(embed=embed) + return + + # Use the earliest skipped pick + pick_to_use = skipped_picks[0] + self.logger.info( + f"Team {team.abbrev} using skipped pick #{pick_to_use.overall} " + f"(current pick is #{current_pick.overall})" ) - await interaction.followup.send(embed=embed) - return # Get player 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) 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: embed = await create_pick_illegal_embed( "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) 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( - current_pick.id, + pick_to_use.id, player_obj.id ) @@ -248,32 +275,145 @@ class DraftPicksCog(commands.Cog): if not updated_player: self.logger.error(f"Failed to update player {player_obj.id} team") + # Write pick to Google Sheets (fire-and-forget with notification on failure) + await self._write_pick_to_sheets( + draft_data=draft_data, + pick=pick_to_use, + player=player_obj, + team=team, + guild=interaction.guild + ) + + # Determine if this was a skipped pick + is_skipped_pick = pick_to_use.overall != current_pick.overall + # Send success message success_embed = await create_pick_success_embed( player_obj, team, - current_pick.overall, - projected_total + pick_to_use.overall, + 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) - # Post draft card to ping channel - if draft_data.ping_channel: + # Post draft card to ping channel (only if different from command channel) + if draft_data.ping_channel and draft_data.ping_channel != interaction.channel_id: guild = interaction.guild if guild: ping_channel = guild.get_channel(draft_data.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) - # Advance to next pick - await draft_service.advance_pick(draft_data.id, draft_data.currentpick) + # 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) self.logger.info( 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): """Load the draft picks cog.""" diff --git a/commands/draft/status.py b/commands/draft/status.py index de83105..376a196 100644 --- a/commands/draft/status.py +++ b/commands/draft/status.py @@ -71,8 +71,11 @@ class DraftStatusCommands(commands.Cog): else: lock_status = "πŸ”’ Pick in progress (system)" + # Get draft sheet URL + sheet_url = config.get_draft_sheet_url(config.sba_season) + # Create status embed - embed = await create_draft_status_embed(draft_data, current_pick, lock_status) + embed = await create_draft_status_embed(draft_data, current_pick, lock_status, sheet_url) await interaction.followup.send(embed=embed) @discord.app_commands.command( @@ -133,13 +136,17 @@ class DraftStatusCommands(commands.Cog): if roster and roster.get('active'): 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 embed = await create_on_the_clock_embed( current_pick, draft_data, recent_picks, upcoming_picks, - team_roster_swar + team_roster_swar, + sheet_url ) await interaction.followup.send(embed=embed) diff --git a/commands/utilities/charts.py b/commands/utilities/charts.py index 9e144f9..e3b1b9c 100644 --- a/commands/utilities/charts.py +++ b/commands/utilities/charts.py @@ -608,9 +608,10 @@ class ChartCategoryGroup(app_commands.Group): categories = self.chart_service.get_categories() if not categories: - embed = EmbedTemplate.info( + embed = EmbedTemplate.create_base_embed( 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) return diff --git a/config.py b/config.py index 48f46a8..2bd898f 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,9 @@ """ Configuration management for Discord Bot v2.0 """ +import os +from typing import Optional + from pydantic_settings import BaseSettings, SettingsConfigDict # Baseball position constants (static, not configurable) @@ -84,6 +87,12 @@ class BotConfig(BaseSettings): # Google Sheets settings sheets_credentials_path: str = "/app/data/major-domo-service-creds.json" + # Draft Sheet settings (for writing picks to Google Sheets) + # Sheet IDs can be overridden via environment variables: DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc. + draft_sheet_enabled: bool = True # Feature flag - set DRAFT_SHEET_ENABLED=false to disable + draft_sheet_worksheet: str = "Ordered List" # Worksheet name to write picks to + draft_sheet_start_column: str = "D" # Column where pick data starts (D, E, F, G for 4 columns) + # Giphy API settings giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD" giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate" @@ -113,6 +122,42 @@ class BotConfig(BaseSettings): """Calculate total picks in draft (derived value).""" return self.draft_rounds * self.draft_team_count + def get_draft_sheet_key(self, season: int) -> Optional[str]: + """ + Get the Google Sheet ID for a given draft season. + + Sheet IDs are configured via environment variables: + - DRAFT_SHEET_KEY_12 for season 12 + - DRAFT_SHEET_KEY_13 for season 13 + - etc. + + Returns None if no sheet is configured for the season. + """ + # Default sheet IDs (hardcoded as fallback) + default_keys = { + 12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU", + 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 _config = None diff --git a/models/draft_data.py b/models/draft_data.py index 2dcbe36..058f79e 100644 --- a/models/draft_data.py +++ b/models/draft_data.py @@ -15,6 +15,7 @@ class DraftData(SBABaseModel): currentpick: int = Field(0, description="Current pick number in progress") 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") 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") @@ -32,16 +33,26 @@ class DraftData(SBABaseModel): @property def is_draft_active(self) -> bool: - """Check if the draft is currently active.""" - return self.timer - + """Check if the draft is currently active (timer running and not paused).""" + return self.timer and not self.paused + @property def is_pick_expired(self) -> bool: """Check if the current pick deadline has passed.""" if not self.pick_deadline: return False 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): - 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)" \ No newline at end of file diff --git a/models/draft_list.py b/models/draft_list.py index 920570f..709675e 100644 --- a/models/draft_list.py +++ b/models/draft_list.py @@ -3,7 +3,7 @@ Draft preference list model Represents team draft board rankings and preferences. """ -from typing import Optional +from typing import Optional, Dict, Any from pydantic import Field from models.base import SBABaseModel @@ -21,6 +21,32 @@ class DraftList(SBABaseModel): team: Team = Field(..., description="Team 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 def team_id(self) -> int: """Extract team ID from nested team object.""" diff --git a/models/draft_pick.py b/models/draft_pick.py index 3a39dc5..948fb3a 100644 --- a/models/draft_pick.py +++ b/models/draft_pick.py @@ -2,9 +2,15 @@ Draft pick model 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 pydantic import Field +from typing import Optional, Any, Dict, Union +from pydantic import Field, field_validator, model_validator from models.base import SBABaseModel from models.team import Team @@ -13,21 +19,79 @@ from models.player import Player class DraftPick(SBABaseModel): """Draft pick model representing a single draft selection.""" - + season: int = Field(..., description="Draft season") overall: int = Field(..., description="Overall pick number") 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: 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: 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: 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 def is_traded(self) -> bool: diff --git a/models/team.py b/models/team.py index 10f9ace..a21d365 100644 --- a/models/team.py +++ b/models/team.py @@ -47,6 +47,7 @@ class Team(SBABaseModel): thumbnail: Optional[str] = Field(None, description="Team thumbnail URL") color: Optional[str] = Field(None, description="Primary team 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 def from_api_data(cls, data: dict) -> 'Team': diff --git a/services/CLAUDE.md b/services/CLAUDE.md index 41e65e3..7a32cc9 100644 --- a/services/CLAUDE.md +++ b/services/CLAUDE.md @@ -377,6 +377,65 @@ class SheetsService: async def read_box_score(scorecard: pygsheets.Spreadsheet) -> Dict[str, List[int]] ``` +#### DraftSheetService Key Methods (NEW - December 2025) +```python +class DraftSheetService(SheetsService): + """ + Service for writing draft picks to Google Sheets. + Extends SheetsService to reuse authentication and async patterns. + """ + async def write_pick( + season: int, + overall: int, + orig_owner_abbrev: str, + owner_abbrev: str, + player_name: str, + swar: float + ) -> bool + """Write a single pick to the draft sheet. Returns True on success.""" + + async def write_picks_batch( + season: int, + picks: List[Tuple[int, str, str, str, float]] + ) -> Tuple[int, int] + """Batch write picks for resync operations. Returns (success_count, failure_count).""" + + async def clear_picks_range( + season: int, + start_overall: int = 1, + end_overall: int = 512 + ) -> bool + """Clear a range of picks from the sheet before resync.""" + + def get_sheet_url(season: int) -> Optional[str] + """Get the draft sheet URL for display in embeds.""" +``` + +**Integration Points:** +- `commands/draft/picks.py` - Writes pick to sheet after successful draft selection +- `tasks/draft_monitor.py` - Writes pick to sheet after auto-draft +- `commands/draft/admin.py` - `/draft-admin resync-sheet` for bulk recovery + +**Fire-and-Forget Pattern:** +Draft sheet writes are non-critical (database is source of truth). On failure: +1. Log the error +2. Notify ping channel with warning message +3. Suggest `/draft-admin resync-sheet` for recovery +4. Never block the draft pick itself + +**Configuration:** +```python +# config.py settings +draft_sheet_enabled: bool = True # Feature flag +draft_sheet_worksheet: str = "Ordered List" # Worksheet name +draft_sheet_start_column: str = "D" # Starting column + +# Season-specific sheet IDs via environment variables +# DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc. +config.get_draft_sheet_key(season) # Returns sheet ID or None +config.get_draft_sheet_url(season) # Returns full URL or None +``` + **Transaction Rollback Pattern:** The game submission services implement a 3-state transaction rollback pattern: 1. **PLAYS_POSTED**: Plays submitted β†’ Rollback: Delete plays diff --git a/services/__init__.py b/services/__init__.py index f41f6ea..39af11d 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -9,6 +9,7 @@ from .player_service import PlayerService, player_service from .league_service import LeagueService, league_service from .schedule_service import ScheduleService, schedule_service from .giphy_service import GiphyService +from .draft_sheet_service import DraftSheetService, get_draft_sheet_service # Wire services together for dependency injection player_service._team_service = team_service @@ -21,5 +22,6 @@ __all__ = [ 'PlayerService', 'player_service', 'LeagueService', 'league_service', 'ScheduleService', 'schedule_service', - 'GiphyService', 'giphy_service' + 'GiphyService', 'giphy_service', + 'DraftSheetService', 'get_draft_sheet_service' ] \ No newline at end of file diff --git a/services/draft_pick_service.py b/services/draft_pick_service.py index abbd664..e7508a0 100644 --- a/services/draft_pick_service.py +++ b/services/draft_pick_service.py @@ -33,6 +33,33 @@ class DraftPickService(BaseService[DraftPick]): super().__init__(DraftPick, 'draftpicks') 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]: """ Get specific pick by season and overall number. @@ -181,6 +208,52 @@ class DraftPickService(BaseService[DraftPick]): logger.error(f"Error getting available picks: {e}") 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( self, season: int, @@ -252,6 +325,35 @@ class DraftPickService(BaseService[DraftPick]): logger.error(f"Error getting upcoming picks: {e}") return [] + async def get_picks_with_players(self, season: int) -> List[DraftPick]: + """ + Get all picks for a season with player data included. + + Used for bulk operations like resync-sheet. Returns all picks + for the season regardless of whether they have been selected. + + NOT cached - picks change during draft. + + Args: + season: Draft season + + Returns: + List of all DraftPick instances for the season + """ + try: + params = [ + ('season', str(season)), + ('sort', 'order-asc') + ] + + picks = await self.get_all_items(params=params) + logger.debug(f"Found {len(picks)} picks for season {season}") + return picks + + except Exception as e: + logger.error(f"Error getting all picks for season {season}: {e}") + return [] + async def update_pick_selection( self, pick_id: int, diff --git a/services/draft_service.py b/services/draft_service.py index d47f23a..991334a 100644 --- a/services/draft_service.py +++ b/services/draft_service.py @@ -338,6 +338,82 @@ class DraftService(BaseService[DraftData]): logger.error(f"Error resetting draft deadline: {e}") 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 draft_service = DraftService() diff --git a/services/draft_sheet_service.py b/services/draft_sheet_service.py new file mode 100644 index 0000000..61af59b --- /dev/null +++ b/services/draft_sheet_service.py @@ -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 diff --git a/services/player_service.py b/services/player_service.py index 569047a..117cd94 100644 --- a/services/player_service.py +++ b/services/player_service.py @@ -245,15 +245,35 @@ class PlayerService(BaseService[Player]): async def is_free_agent(self, player: Player) -> bool: """ Check if a player is a free agent. - + Args: player: Player instance to check - + Returns: True if player is a free agent """ 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]: """ Get players by position. diff --git a/tasks/CLAUDE.md b/tasks/CLAUDE.md index 63b8724..3f75f71 100644 --- a/tasks/CLAUDE.md +++ b/tasks/CLAUDE.md @@ -231,16 +231,27 @@ When voice channels are cleaned up (deleted after being empty): - Prevents duplicate error messages - 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 -**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:** - **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` - - 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:** - 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 #### 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) - **Global Lock Integration:** Acquires same lock as `/draft` command - **Crash Recovery:** Respects 30-second stale lock timeout @@ -301,8 +314,30 @@ async with draft_picks_cog.pick_lock: - Validate cap space - Attempt to draft player - Break on success -5. Advance to next pick -6. Release lock +5. Write pick to Google Sheets (fire-and-forget) +6. Advance to next pick +7. Release lock + +#### Google Sheets Integration +The monitor writes picks to the draft sheet after successful auto-draft: +- Uses **fire-and-forget** pattern (non-blocking) +- Failures logged but don't block draft +- Same service as manual `/draft` command +- Sheet write occurs before pick advancement + +```python +# After successful auto-draft execution +sheet_success = await draft_sheet_service.write_pick( + season=config.sba_season, + overall=pick.overall, + orig_owner_abbrev=original_owner.abbrev, + owner_abbrev=team.abbrev, + player_name=player.name, + swar=player.wara +) +if not sheet_success: + logger.warning(f"Sheet write failed for auto-draft pick #{pick.overall}") +``` #### Channel Requirements - **ping_channel** - Where warnings and auto-draft announcements post diff --git a/tasks/draft_monitor.py b/tasks/draft_monitor.py index e1e21a7..28d1684 100644 --- a/tasks/draft_monitor.py +++ b/tasks/draft_monitor.py @@ -14,10 +14,14 @@ from discord.ext import commands, tasks from services.draft_service import draft_service from services.draft_pick_service import draft_pick_service from services.draft_list_service import draft_list_service +from services.draft_sheet_service import get_draft_sheet_service from services.player_service import player_service from services.team_service import team_service +from services.roster_service import roster_service from utils.logging import get_contextual_logger +from utils.helpers import get_team_salary_cap from views.embeds import EmbedTemplate, EmbedColors +from views.draft_views import create_on_clock_announcement_embed from config import get_config @@ -50,10 +54,35 @@ class DraftMonitorTask: """Stop the task when cog is unloaded.""" 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): """ - 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. """ @@ -71,6 +100,11 @@ class DraftMonitorTask: self.monitor_loop.cancel() 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 now = datetime.now() deadline = draft_data.pick_deadline @@ -82,6 +116,12 @@ class DraftMonitorTask: # Calculate time remaining 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: # Timer expired - auto-draft await self._handle_expired_timer(draft_data) @@ -180,25 +220,43 @@ class DraftMonitorTask: ) # Advance to next pick 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 # Try each player in order for entry in draft_list: if not entry.player: + self.logger.debug(f"Draft list entry has no player, skipping") continue 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 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 # Attempt to draft this player success = await self._attempt_draft_player( current_pick, player, - ping_channel + ping_channel, + draft_data, + guild ) if success: @@ -207,6 +265,8 @@ class DraftMonitorTask: ) # Advance to next pick 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 @@ -219,6 +279,11 @@ class DraftMonitorTask: ) # Advance to next pick anyway 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: self.logger.error("Error auto-drafting player", error=e) @@ -227,7 +292,9 @@ class DraftMonitorTask: self, draft_pick, player, - ping_channel + ping_channel, + draft_data, + guild ) -> bool: """ Attempt to draft a specific player. @@ -236,6 +303,8 @@ class DraftMonitorTask: draft_pick: DraftPick to update player: Player to draft ping_channel: Discord channel for announcements + draft_data: Draft configuration (for result_channel) + guild: Discord guild (for channel lookup) Returns: True if draft succeeded @@ -252,7 +321,7 @@ class DraftMonitorTask: return False # 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: self.logger.debug( @@ -282,18 +351,110 @@ class DraftMonitorTask: self.logger.error(f"Failed to update player {player.id} team") 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( content=f"πŸ€– AUTO-DRAFT: {draft_pick.owner.abbrev} selects **{player.name}** " 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 except Exception as e: self.logger.error(f"Error attempting to draft {player.name}", error=e) 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): """ Send warnings at 60s and 30s remaining. @@ -350,6 +511,65 @@ class DraftMonitorTask: except Exception as e: self.logger.error("Error sending warnings", error=e) + async def _write_pick_to_sheets(self, draft_pick, player, ping_channel) -> None: + """ + Write pick to Google Sheets (fire-and-forget with notification on failure). + + Args: + draft_pick: The draft pick being used + player: Player being drafted + ping_channel: Discord channel for failure notification + """ + config = get_config() + + try: + draft_sheet_service = get_draft_sheet_service() + success = await draft_sheet_service.write_pick( + season=config.sba_season, + overall=draft_pick.overall, + orig_owner_abbrev=draft_pick.origowner.abbrev if draft_pick.origowner else draft_pick.owner.abbrev, + owner_abbrev=draft_pick.owner.abbrev, + player_name=player.name, + swar=player.wara + ) + + if not success: + # Write failed - notify in ping channel + await self._notify_sheet_failure( + ping_channel=ping_channel, + pick_overall=draft_pick.overall, + player_name=player.name + ) + + except Exception as e: + self.logger.warning(f"Failed to write pick to sheets: {e}") + await self._notify_sheet_failure( + ping_channel=ping_channel, + pick_overall=draft_pick.overall, + player_name=player.name + ) + + async def _notify_sheet_failure(self, ping_channel, pick_overall: int, player_name: str) -> None: + """ + Post notification to ping channel when sheet write fails. + + Args: + ping_channel: Discord channel to notify + pick_overall: Pick number that failed + player_name: Player name + """ + if not ping_channel: + return + + try: + await ping_channel.send( + f"⚠️ **Sheet Sync Failed** - Pick #{pick_overall} ({player_name}) " + f"was not written to the draft sheet. " + f"Use `/draft-admin resync-sheet` to manually sync." + ) + except Exception as e: + self.logger.error(f"Failed to send sheet failure notification: {e}") + # Task factory function def setup_draft_monitor(bot: commands.Bot) -> DraftMonitorTask: diff --git a/tests/test_models.py b/tests/test_models.py index cee5161..34f734a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -474,6 +474,68 @@ class TestDraftListModel: assert top_pick.is_top_ranked is True 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: """Additional model coverage tests.""" diff --git a/tests/test_services_draft.py b/tests/test_services_draft.py index 938c713..a3cc56e 100644 --- a/tests/test_services_draft.py +++ b/tests/test_services_draft.py @@ -39,13 +39,14 @@ def create_draft_data(**overrides) -> dict: """ 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 """ base_data = { 'id': 1, 'currentpick': 25, 'timer': True, + 'paused': False, # New field for draft pause feature 'pick_deadline': (datetime.now() + timedelta(minutes=10)).isoformat(), 'result_channel': '123456789012345678', # 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]['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 @@ -775,6 +930,89 @@ class TestDraftPickService: assert patch_data['player_id'] is None 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 @@ -1279,6 +1517,72 @@ class TestDraftDataModel: assert active.is_draft_active is True 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): """Test is_pick_expired property.""" # Expired deadline diff --git a/tests/test_services_draft_sheet.py b/tests/test_services_draft_sheet.py new file mode 100644 index 0000000..96478fe --- /dev/null +++ b/tests/test_services_draft_sheet.py @@ -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 diff --git a/tests/test_utils_draft_helpers.py b/tests/test_utils_draft_helpers.py new file mode 100644 index 0000000..4217509 --- /dev/null +++ b/tests/test_utils_draft_helpers.py @@ -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 diff --git a/tests/test_utils_helpers.py b/tests/test_utils_helpers.py new file mode 100644 index 0000000..f667e14 --- /dev/null +++ b/tests/test_utils_helpers.py @@ -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 diff --git a/utils/draft_helpers.py b/utils/draft_helpers.py index 8cd4386..60dbc1c 100644 --- a/utils/draft_helpers.py +++ b/utils/draft_helpers.py @@ -110,15 +110,16 @@ def calculate_overall_from_round_position(round_num: int, position: int) -> int: async def validate_cap_space( roster: dict, - new_player_wara: float -) -> Tuple[bool, float]: + new_player_wara: float, + team=None +) -> Tuple[bool, float, float]: """ Validate team has cap space to draft player. Cap calculation: - Maximum 32 players on active roster - Only top 26 players count toward cap - - Cap limit: 32.00 sWAR total + - Cap limit: Team-specific or default 32.00 sWAR Args: 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 + team: Optional team object/dict for team-specific salary cap 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: ValueError: If roster structure is invalid """ + from utils.helpers import get_team_salary_cap, SALARY_CAP_TOLERANCE + config = get_config() - cap_limit = config.swar_cap_limit + cap_limit = get_team_salary_cap(team) cap_player_count = config.cap_player_count if not roster or not roster.get('active'): @@ -150,31 +154,34 @@ async def validate_cap_space( current_roster_size = len(current_players) projected_roster_size = current_roster_size + 1 - # Maximum zeroes = 32 - roster size - # Maximum counted = 26 - zeroes - max_zeroes = 32 - projected_roster_size - max_counted = min(cap_player_count, cap_player_count - max_zeroes) # Can't count more than cap_player_count + # Cap counting rules: + # - The 26 CHEAPEST (lowest WAR) players on the roster count toward the cap + # - If roster has fewer than 26 players, all of them 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] - sorted_wara = sorted(all_players_wara, reverse=True) + sorted_wara = sorted(all_players_wara) # Ascending order - # Sum top N players - projected_total = sum(sorted_wara[:max_counted]) + # Sum bottom N players (the cheapest ones) + projected_total = sum(sorted_wara[:players_counted]) # Allow tiny floating point tolerance - is_valid = projected_total <= (cap_limit + 0.00001) + is_valid = projected_total <= (cap_limit + SALARY_CAP_TOLERANCE) logger.debug( f"Cap validation: roster_size={current_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"projected_total={projected_total:.2f}, " + f"cap_limit={cap_limit:.2f}, " f"valid={is_valid}" ) - return is_valid, projected_total + return is_valid, projected_total, cap_limit def format_pick_display(overall: int) -> str: diff --git a/utils/helpers.py b/utils/helpers.py new file mode 100644 index 0000000..ccdeaba --- /dev/null +++ b/utils/helpers.py @@ -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) diff --git a/views/draft_views.py b/views/draft_views.py index 9ee08d6..264deb5 100644 --- a/views/draft_views.py +++ b/views/draft_views.py @@ -23,7 +23,8 @@ async def create_on_the_clock_embed( draft_data: DraftData, recent_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: """ 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 upcoming_picks: List of upcoming draft picks team_roster_swar: Current team sWAR (optional) + sheet_url: Optional Google Sheets URL for draft tracking Returns: Discord embed with pick information @@ -67,10 +69,11 @@ async def create_on_the_clock_embed( # Add team sWAR if provided 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( 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 ) @@ -99,6 +102,14 @@ async def create_on_the_clock_embed( 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 if current_pick.is_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( draft_data: DraftData, current_pick: DraftPick, - lock_status: str = "πŸ”“ No pick in progress" + lock_status: str = "πŸ”“ No pick in progress", + sheet_url: Optional[str] = None ) -> discord.Embed: """ Create draft status embed showing current state. @@ -118,14 +130,22 @@ async def create_draft_status_embed( draft_data: Current draft configuration current_pick: Current DraftPick lock_status: Lock status message + sheet_url: Optional Google Sheets URL for draft tracking Returns: Discord embed with draft status """ - embed = EmbedTemplate.info( - title="Draft Status", - description=f"Currently on {format_pick_display(draft_data.currentpick)}" - ) + # 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( + title="Draft Status", + description=f"Currently on {format_pick_display(draft_data.currentpick)}" + ) # On the clock if current_pick.owner: @@ -135,8 +155,13 @@ async def create_draft_status_embed( inline=True ) - # Timer status - timer_status = "βœ… Active" if draft_data.timer else "⏹️ Inactive" + # Timer status (show paused state prominently) + if draft_data.paused: + timer_status = "⏸️ PAUSED" + elif draft_data.timer: + timer_status = "βœ… Active" + else: + timer_status = "⏹️ Inactive" embed.add_field( name="Timer", value=f"{timer_status} ({draft_data.pick_minutes} min)", @@ -158,6 +183,14 @@ async def create_draft_status_embed( 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 embed.add_field( name="Lock Status", @@ -165,6 +198,14 @@ async def create_draft_status_embed( inline=False ) + # Draft Sheet link + if sheet_url: + embed.add_field( + name="Draft Sheet", + value=f"[View Sheet]({sheet_url})", + inline=False + ) + return embed @@ -258,14 +299,15 @@ async def create_draft_list_embed( 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 async def create_draft_board_embed( round_num: int, - picks: List[DraftPick] + picks: List[DraftPick], + sheet_url: Optional[str] = None ) -> discord.Embed: """ Create draft board embed showing all picks in a round. @@ -273,6 +315,7 @@ async def create_draft_board_embed( Args: round_num: Round number picks: List of DraftPick for this round + sheet_url: Optional Google Sheets URL for draft tracking Returns: Discord embed with draft board @@ -299,7 +342,10 @@ async def create_draft_board_embed( player_display = "TBD" 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( name="Picks", @@ -307,6 +353,14 @@ async def create_draft_board_embed( 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") return embed @@ -345,7 +399,8 @@ async def create_pick_success_embed( player: Player, team: Team, pick_overall: int, - projected_swar: float + projected_swar: float, + cap_limit: float | None = None ) -> discord.Embed: """ Create embed for successful pick. @@ -355,33 +410,44 @@ async def create_pick_success_embed( team: Team that drafted player pick_overall: Overall pick number projected_swar: Projected team sWAR after pick + cap_limit: Team's salary cap limit (optional, uses helper if not provided) Returns: Discord success embed """ + from utils.helpers import get_team_salary_cap + embed = EmbedTemplate.success( - title="Pick Confirmed", - description=f"{team.abbrev} selects **{player.name}**" + title=f"{team.sname} select **{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( - name="Pick", - value=format_pick_display(pick_overall), + name="Player ID", + value=f"{player.id}", inline=True ) if hasattr(player, 'wara') and player.wara is not None: embed.add_field( - name="Player sWAR", + name="sWAR", value=f"{player.wara:.2f}", 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( name="Projected Team sWAR", - value=f"{projected_swar:.2f} / {config.swar_cap_limit:.2f}", - inline=True + value=f"{projected_swar:.2f} / {cap_limit:.2f}", + inline=False ) return embed @@ -389,7 +455,8 @@ async def create_pick_success_embed( async def create_admin_draft_info_embed( draft_data: DraftData, - current_pick: Optional[DraftPick] = None + current_pick: Optional[DraftPick] = None, + sheet_url: Optional[str] = None ) -> discord.Embed: """ Create detailed admin view of draft status. @@ -397,14 +464,24 @@ async def create_admin_draft_info_embed( Args: draft_data: Current draft configuration current_pick: Current DraftPick (optional) + sheet_url: Optional Google Sheets URL for draft tracking Returns: Discord embed with admin information """ - embed = EmbedTemplate.info( - title="βš™οΈ Draft Administration", - description="Current draft configuration and state" - ) + # 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", + description="Current draft configuration and state", + color=EmbedColors.INFO + ) # Current pick embed.add_field( @@ -413,11 +490,20 @@ async def create_admin_draft_info_embed( inline=True ) - # Timer status - timer_emoji = "βœ…" if draft_data.timer else "⏹️" + # Timer status (show paused prominently) + 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( name="Timer Status", - value=f"{timer_emoji} {'Active' if draft_data.timer else 'Inactive'}", + value=f"{timer_emoji} {timer_text}", inline=True ) @@ -428,6 +514,14 @@ async def create_admin_draft_info_embed( 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 ping_channel_value = f"<#{draft_data.ping_channel}>" if draft_data.ping_channel else "Not configured" embed.add_field( @@ -460,6 +554,124 @@ async def create_admin_draft_info_embed( 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") 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" ()", + 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