From 858663cd2792034e52e1f1148f52c3e763343a40 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 2 Mar 2026 13:35:23 -0600 Subject: [PATCH 1/2] refactor: move 42 unnecessary lazy imports to top-level across codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codebase audit identified ~50 lazy imports. Moved 42 unnecessary ones to top-level imports — only keeping those justified by circular imports, init-order dependencies, or optional dependency guards. Updated test mock patch targets where needed. See #57 for remaining DI candidates. Co-Authored-By: Claude Opus 4.6 --- bot.py | 3 +- commands/draft/admin.py | 165 +++++------- commands/draft/picks.py | 3 +- commands/draft/status.py | 54 ++-- commands/injuries/management.py | 7 +- commands/players/info.py | 3 +- commands/profile/images.py | 169 ++++++------ commands/soak/info.py | 68 +++-- services/draft_service.py | 5 +- services/roster_service.py | 3 +- services/schedule_service.py | 169 ++++++------ services/sheets_service.py | 3 +- services/standings_service.py | 130 ++++----- services/stats_service.py | 133 +++++----- tasks/draft_monitor.py | 17 +- tests/test_services_draft.py | 4 +- tests/test_views_injury_modals.py | 315 +++++++++++----------- utils/autocomplete.py | 44 ++-- utils/discord_helpers.py | 31 +-- utils/draft_helpers.py | 16 +- views/draft_views.py | 259 +++++++----------- views/help_commands.py | 194 ++++++++------ views/modals.py | 421 +++++++++++++++--------------- views/trade_embed.py | 342 ++++++++++++++---------- 24 files changed, 1267 insertions(+), 1291 deletions(-) diff --git a/bot.py b/bot.py index ca1c07e..b0fdef0 100644 --- a/bot.py +++ b/bot.py @@ -17,14 +17,13 @@ from discord.ext import commands from config import get_config from exceptions import BotException from api.client import get_global_client, cleanup_global_client +from utils.logging import JSONFormatter from utils.random_gen import STARTUP_WATCHING, random_from_list from views.embeds import EmbedTemplate def setup_logging(): """Configure hybrid logging: human-readable console + structured JSON files.""" - from utils.logging import JSONFormatter - # Create logs directory if it doesn't exist os.makedirs("logs", exist_ok=True) diff --git a/commands/draft/admin.py b/commands/draft/admin.py index 01c41d8..5ebffb5 100644 --- a/commands/draft/admin.py +++ b/commands/draft/admin.py @@ -3,6 +3,7 @@ Draft Admin Commands Admin-only commands for draft management and configuration. """ + from typing import Optional import discord @@ -16,6 +17,7 @@ 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 +from utils.draft_helpers import format_pick_display from views.draft_views import create_admin_draft_info_embed from views.embeds import EmbedTemplate @@ -25,11 +27,10 @@ class DraftAdminGroup(app_commands.Group): def __init__(self, bot: commands.Bot): super().__init__( - name="draft-admin", - description="Admin commands for draft management" + name="draft-admin", description="Admin commands for draft management" ) self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.DraftAdminGroup') + self.logger = get_contextual_logger(f"{__name__}.DraftAdminGroup") def _ensure_monitor_running(self) -> str: """ @@ -40,7 +41,7 @@ class DraftAdminGroup(app_commands.Group): """ from tasks.draft_monitor import setup_draft_monitor - if not hasattr(self.bot, 'draft_monitor') or self.bot.draft_monitor is None: + 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" @@ -63,8 +64,7 @@ class DraftAdminGroup(app_commands.Group): draft_data = await draft_service.get_draft_data() if not draft_data: embed = EmbedTemplate.error( - "Draft Not Found", - "Could not retrieve draft configuration." + "Draft Not Found", "Could not retrieve draft configuration." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -72,8 +72,7 @@ class DraftAdminGroup(app_commands.Group): # Get current pick config = get_config() current_pick = await draft_pick_service.get_pick( - config.sba_season, - draft_data.currentpick + config.sba_season, draft_data.currentpick ) # Get sheet URL @@ -86,7 +85,7 @@ class DraftAdminGroup(app_commands.Group): @app_commands.command(name="timer", description="Enable or disable draft timer") @app_commands.describe( enabled="Turn timer on or off", - minutes="Minutes per pick (optional, default uses current setting)" + minutes="Minutes per pick (optional, default uses current setting)", ) @league_admin_only() @logged_command("/draft-admin timer") @@ -94,7 +93,7 @@ class DraftAdminGroup(app_commands.Group): self, interaction: discord.Interaction, enabled: bool, - minutes: Optional[int] = None + minutes: Optional[int] = None, ): """Enable or disable the draft timer.""" await interaction.response.defer() @@ -103,8 +102,7 @@ class DraftAdminGroup(app_commands.Group): draft_data = await draft_service.get_draft_data() if not draft_data: embed = EmbedTemplate.error( - "Draft Not Found", - "Could not retrieve draft configuration." + "Draft Not Found", "Could not retrieve draft configuration." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -114,8 +112,7 @@ class DraftAdminGroup(app_commands.Group): if not updated: embed = EmbedTemplate.error( - "Update Failed", - "Failed to update draft timer." + "Update Failed", "Failed to update draft timer." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -148,15 +145,11 @@ class DraftAdminGroup(app_commands.Group): await interaction.followup.send(embed=embed) @app_commands.command(name="set-pick", description="Set current pick number") - @app_commands.describe( - pick_number="Overall pick number to jump to (1-512)" - ) + @app_commands.describe(pick_number="Overall pick number to jump to (1-512)") @league_admin_only() @logged_command("/draft-admin set-pick") async def draft_admin_set_pick( - self, - interaction: discord.Interaction, - pick_number: int + self, interaction: discord.Interaction, pick_number: int ): """Set the current pick number (admin operation).""" await interaction.response.defer() @@ -167,7 +160,7 @@ class DraftAdminGroup(app_commands.Group): if pick_number < 1 or pick_number > config.draft_total_picks: embed = EmbedTemplate.error( "Invalid Pick Number", - f"Pick number must be between 1 and {config.draft_total_picks}." + f"Pick number must be between 1 and {config.draft_total_picks}.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -176,8 +169,7 @@ class DraftAdminGroup(app_commands.Group): draft_data = await draft_service.get_draft_data() if not draft_data: embed = EmbedTemplate.error( - "Draft Not Found", - "Could not retrieve draft configuration." + "Draft Not Found", "Could not retrieve draft configuration." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -186,38 +178,36 @@ class DraftAdminGroup(app_commands.Group): pick = await draft_pick_service.get_pick(config.sba_season, pick_number) if not pick: embed = EmbedTemplate.error( - "Pick Not Found", - f"Pick #{pick_number} does not exist in the database." + "Pick Not Found", f"Pick #{pick_number} does not exist in the database." ) await interaction.followup.send(embed=embed, ephemeral=True) return # Update current pick updated = await draft_service.set_current_pick( - draft_data.id, - pick_number, - reset_timer=True + draft_data.id, pick_number, reset_timer=True ) if not updated: embed = EmbedTemplate.error( - "Update Failed", - "Failed to update current pick." + "Update Failed", "Failed to update current pick." ) await interaction.followup.send(embed=embed, ephemeral=True) return # Success message - from utils.draft_helpers import format_pick_display - description = f"Current pick set to **{format_pick_display(pick_number)}**." if pick.owner: - description += f"\n\n{pick.owner.abbrev} {pick.owner.sname} is now on the clock." + description += ( + f"\n\n{pick.owner.abbrev} {pick.owner.sname} is now on the clock." + ) # Add timer status and ensure monitor is running if timer is active if updated.timer and updated.pick_deadline: deadline_timestamp = int(updated.pick_deadline.timestamp()) - description += f"\n\nā±ļø **Timer Active** - Deadline " + description += ( + f"\n\nā±ļø **Timer Active** - Deadline " + ) # Ensure monitor is running monitor_status = self._ensure_monitor_running() description += monitor_status @@ -227,10 +217,12 @@ class DraftAdminGroup(app_commands.Group): embed = EmbedTemplate.success("Pick Updated", description) await interaction.followup.send(embed=embed) - @app_commands.command(name="channels", description="Configure draft Discord channels") + @app_commands.command( + name="channels", description="Configure draft Discord channels" + ) @app_commands.describe( ping_channel="Channel for 'on the clock' pings", - result_channel="Channel for draft results" + result_channel="Channel for draft results", ) @league_admin_only() @logged_command("/draft-admin channels") @@ -238,15 +230,14 @@ class DraftAdminGroup(app_commands.Group): self, interaction: discord.Interaction, ping_channel: Optional[discord.TextChannel] = None, - result_channel: Optional[discord.TextChannel] = None + result_channel: Optional[discord.TextChannel] = None, ): """Configure draft Discord channels.""" await interaction.response.defer() if not ping_channel and not result_channel: embed = EmbedTemplate.error( - "No Channels Provided", - "Please specify at least one channel to update." + "No Channels Provided", "Please specify at least one channel to update." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -255,8 +246,7 @@ class DraftAdminGroup(app_commands.Group): draft_data = await draft_service.get_draft_data() if not draft_data: embed = EmbedTemplate.error( - "Draft Not Found", - "Could not retrieve draft configuration." + "Draft Not Found", "Could not retrieve draft configuration." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -265,13 +255,12 @@ class DraftAdminGroup(app_commands.Group): updated = await draft_service.update_channels( draft_data.id, ping_channel_id=ping_channel.id if ping_channel else None, - result_channel_id=result_channel.id if result_channel else None + result_channel_id=result_channel.id if result_channel else None, ) if not updated: embed = EmbedTemplate.error( - "Update Failed", - "Failed to update draft channels." + "Update Failed", "Failed to update draft channels." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -286,16 +275,14 @@ class DraftAdminGroup(app_commands.Group): embed = EmbedTemplate.success("Channels Updated", description) await interaction.followup.send(embed=embed) - @app_commands.command(name="reset-deadline", description="Reset current pick deadline") - @app_commands.describe( - minutes="Minutes to add (uses default if not provided)" + @app_commands.command( + name="reset-deadline", description="Reset current pick deadline" ) + @app_commands.describe(minutes="Minutes to add (uses default if not provided)") @league_admin_only() @logged_command("/draft-admin reset-deadline") async def draft_admin_reset_deadline( - self, - interaction: discord.Interaction, - minutes: Optional[int] = None + self, interaction: discord.Interaction, minutes: Optional[int] = None ): """Reset the current pick deadline.""" await interaction.response.defer() @@ -304,8 +291,7 @@ class DraftAdminGroup(app_commands.Group): draft_data = await draft_service.get_draft_data() if not draft_data: embed = EmbedTemplate.error( - "Draft Not Found", - "Could not retrieve draft configuration." + "Draft Not Found", "Could not retrieve draft configuration." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -313,7 +299,7 @@ class DraftAdminGroup(app_commands.Group): if not draft_data.timer: embed = EmbedTemplate.warning( "Timer Inactive", - "Draft timer is currently disabled. Enable it with `/draft-admin timer on` first." + "Draft timer is currently disabled. Enable it with `/draft-admin timer on` first.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -323,8 +309,7 @@ class DraftAdminGroup(app_commands.Group): if not updated: embed = EmbedTemplate.error( - "Update Failed", - "Failed to reset draft deadline." + "Update Failed", "Failed to reset draft deadline." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -334,7 +319,9 @@ class DraftAdminGroup(app_commands.Group): minutes_used = minutes if minutes else updated.pick_minutes description = f"Pick deadline reset: **{minutes_used} minutes** added.\n\n" - description += f"New deadline: ()" + description += ( + f"New deadline: ()" + ) embed = EmbedTemplate.success("Deadline Reset", description) await interaction.followup.send(embed=embed) @@ -350,8 +337,7 @@ class DraftAdminGroup(app_commands.Group): draft_data = await draft_service.get_draft_data() if not draft_data: embed = EmbedTemplate.error( - "Draft Not Found", - "Could not retrieve draft configuration." + "Draft Not Found", "Could not retrieve draft configuration." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -359,8 +345,7 @@ class DraftAdminGroup(app_commands.Group): # Check if already paused if draft_data.paused: embed = EmbedTemplate.warning( - "Already Paused", - "The draft is already paused." + "Already Paused", "The draft is already paused." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -369,10 +354,7 @@ class DraftAdminGroup(app_commands.Group): updated = await draft_service.pause_draft(draft_data.id) if not updated: - embed = EmbedTemplate.error( - "Pause Failed", - "Failed to pause the draft." - ) + embed = EmbedTemplate.error("Pause Failed", "Failed to pause the draft.") await interaction.followup.send(embed=embed, ephemeral=True) return @@ -400,8 +382,7 @@ class DraftAdminGroup(app_commands.Group): draft_data = await draft_service.get_draft_data() if not draft_data: embed = EmbedTemplate.error( - "Draft Not Found", - "Could not retrieve draft configuration." + "Draft Not Found", "Could not retrieve draft configuration." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -409,8 +390,7 @@ class DraftAdminGroup(app_commands.Group): # Check if already unpaused if not draft_data.paused: embed = EmbedTemplate.warning( - "Not Paused", - "The draft is not currently paused." + "Not Paused", "The draft is not currently paused." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -419,10 +399,7 @@ class DraftAdminGroup(app_commands.Group): updated = await draft_service.resume_draft(draft_data.id) if not updated: - embed = EmbedTemplate.error( - "Resume Failed", - "Failed to resume the draft." - ) + embed = EmbedTemplate.error("Resume Failed", "Failed to resume the draft.") await interaction.followup.send(embed=embed, ephemeral=True) return @@ -432,7 +409,9 @@ class DraftAdminGroup(app_commands.Group): # 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 " + description += ( + f"\n\nā±ļø **Timer Active** - Current deadline " + ) # Ensure monitor is running monitor_status = self._ensure_monitor_running() @@ -441,7 +420,9 @@ class DraftAdminGroup(app_commands.Group): 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") + @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): @@ -458,8 +439,7 @@ class DraftAdminGroup(app_commands.Group): # Check if sheet integration is enabled if not config.draft_sheet_enabled: embed = EmbedTemplate.warning( - "Sheet Disabled", - "Draft sheet integration is currently disabled." + "Sheet Disabled", "Draft sheet integration is currently disabled." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -469,7 +449,7 @@ class DraftAdminGroup(app_commands.Group): if not sheet_url: embed = EmbedTemplate.error( "No Sheet Configured", - f"No draft sheet is configured for season {config.sba_season}." + f"No draft sheet is configured for season {config.sba_season}.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -479,8 +459,7 @@ class DraftAdminGroup(app_commands.Group): if not all_picks: embed = EmbedTemplate.warning( - "No Picks Found", - "No draft picks found for the current season." + "No Picks Found", "No draft picks found for the current season." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -490,8 +469,7 @@ class DraftAdminGroup(app_commands.Group): if not completed_picks: embed = EmbedTemplate.warning( - "No Completed Picks", - "No draft picks have been made yet." + "No Completed Picks", "No draft picks have been made yet." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -499,40 +477,37 @@ class DraftAdminGroup(app_commands.Group): # 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 "???") + 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 - )) + 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 + 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..." + "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 + config.sba_season, pick_data ) # Build result message diff --git a/commands/draft/picks.py b/commands/draft/picks.py index 90cbe3c..dfcdbbe 100644 --- a/commands/draft/picks.py +++ b/commands/draft/picks.py @@ -16,6 +16,7 @@ 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.league_service import league_service from services.player_service import player_service from services.team_service import team_service from services.roster_service import roster_service @@ -290,8 +291,6 @@ class DraftPicksCog(commands.Cog): return # Get current league state for dem_week calculation - from services.league_service import league_service - current = await league_service.get_current_state() # Update player team with dem_week set to current.week + 2 for draft picks diff --git a/commands/draft/status.py b/commands/draft/status.py index 376a196..377a60f 100644 --- a/commands/draft/status.py +++ b/commands/draft/status.py @@ -3,12 +3,14 @@ Draft Status Commands Display current draft state and information. """ + import discord 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.team_service import team_service from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.permissions import requires_team @@ -21,11 +23,11 @@ class DraftStatusCommands(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.DraftStatusCommands') + self.logger = get_contextual_logger(f"{__name__}.DraftStatusCommands") @discord.app_commands.command( name="draft-status", - description="View current draft state and timer information" + description="View current draft state and timer information", ) @requires_team() @logged_command("/draft-status") @@ -39,34 +41,33 @@ class DraftStatusCommands(commands.Cog): draft_data = await draft_service.get_draft_data() if not draft_data: embed = EmbedTemplate.error( - "Draft Not Found", - "Could not retrieve draft configuration." + "Draft Not Found", "Could not retrieve draft configuration." ) await interaction.followup.send(embed=embed, ephemeral=True) return # Get current pick current_pick = await draft_pick_service.get_pick( - config.sba_season, - draft_data.currentpick + config.sba_season, draft_data.currentpick ) if not current_pick: embed = EmbedTemplate.error( - "Pick Not Found", - f"Could not retrieve pick #{draft_data.currentpick}." + "Pick Not Found", f"Could not retrieve pick #{draft_data.currentpick}." ) await interaction.followup.send(embed=embed, ephemeral=True) return # Check pick lock status - draft_picks_cog = self.bot.get_cog('DraftPicksCog') + draft_picks_cog = self.bot.get_cog("DraftPicksCog") lock_status = "šŸ”“ No pick in progress" if draft_picks_cog and draft_picks_cog.pick_lock.locked(): if draft_picks_cog.lock_acquired_by: user = self.bot.get_user(draft_picks_cog.lock_acquired_by) - user_name = user.name if user else f"User {draft_picks_cog.lock_acquired_by}" + user_name = ( + user.name if user else f"User {draft_picks_cog.lock_acquired_by}" + ) lock_status = f"šŸ”’ Pick in progress by {user_name}" else: lock_status = "šŸ”’ Pick in progress (system)" @@ -75,12 +76,13 @@ class DraftStatusCommands(commands.Cog): 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, sheet_url) + embed = await create_draft_status_embed( + draft_data, current_pick, lock_status, sheet_url + ) await interaction.followup.send(embed=embed) @discord.app_commands.command( - name="draft-on-clock", - description="View detailed 'on the clock' information" + name="draft-on-clock", description="View detailed 'on the clock' information" ) @requires_team() @logged_command("/draft-on-clock") @@ -94,47 +96,39 @@ class DraftStatusCommands(commands.Cog): draft_data = await draft_service.get_draft_data() if not draft_data: embed = EmbedTemplate.error( - "Draft Not Found", - "Could not retrieve draft configuration." + "Draft Not Found", "Could not retrieve draft configuration." ) await interaction.followup.send(embed=embed, ephemeral=True) return # Get current pick current_pick = await draft_pick_service.get_pick( - config.sba_season, - draft_data.currentpick + config.sba_season, draft_data.currentpick ) if not current_pick or not current_pick.owner: embed = EmbedTemplate.error( - "Pick Not Found", - f"Could not retrieve pick #{draft_data.currentpick}." + "Pick Not Found", f"Could not retrieve pick #{draft_data.currentpick}." ) await interaction.followup.send(embed=embed, ephemeral=True) return # Get recent picks recent_picks = await draft_pick_service.get_recent_picks( - config.sba_season, - draft_data.currentpick, - limit=5 + config.sba_season, draft_data.currentpick, limit=5 ) # Get upcoming picks upcoming_picks = await draft_pick_service.get_upcoming_picks( - config.sba_season, - draft_data.currentpick, - limit=5 + config.sba_season, draft_data.currentpick, limit=5 ) # Get team roster sWAR (optional) - from services.team_service import team_service team_roster_swar = None - roster = await team_service.get_team_roster(current_pick.owner.id, 'current') - if roster and roster.get('active'): - team_roster_swar = roster['active'].get('WARa') + roster = await team_service.get_team_roster(current_pick.owner.id, "current") + 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) @@ -146,7 +140,7 @@ class DraftStatusCommands(commands.Cog): recent_picks, upcoming_picks, team_roster_swar, - sheet_url + sheet_url, ) await interaction.followup.send(embed=embed) diff --git a/commands/injuries/management.py b/commands/injuries/management.py index d8ef483..d43802b 100644 --- a/commands/injuries/management.py +++ b/commands/injuries/management.py @@ -22,6 +22,7 @@ from models.team import RosterType from services.player_service import player_service from services.injury_service import injury_service from services.league_service import league_service +from services.team_service import team_service from services.giphy_service import GiphyService from utils import team_utils from utils.logging import get_contextual_logger @@ -135,8 +136,6 @@ class InjuryGroup(app_commands.Group): # Fetch full team data if team is not populated if player.team_id and not player.team: - from services.team_service import team_service - player.team = await team_service.get_team(player.team_id) # Check if player already has an active injury @@ -553,8 +552,6 @@ class InjuryGroup(app_commands.Group): # Fetch full team data if team is not populated if player.team_id and not player.team: - from services.team_service import team_service - player.team = await team_service.get_team(player.team_id) # Verify the invoking user owns this player's team @@ -742,8 +739,6 @@ class InjuryGroup(app_commands.Group): # Fetch full team data if team is not populated if player.team_id and not player.team: - from services.team_service import team_service - player.team = await team_service.get_team(player.team_id) # Verify the invoking user owns this player's team diff --git a/commands/players/info.py b/commands/players/info.py index b974351..75bef1f 100644 --- a/commands/players/info.py +++ b/commands/players/info.py @@ -4,6 +4,7 @@ Player Information Commands Implements slash commands for displaying player information and statistics. """ +import asyncio from typing import Optional, List import discord @@ -218,8 +219,6 @@ class PlayerInfoCommands(commands.Cog): ) # Fetch player data and stats concurrently for better performance - import asyncio - player_with_team, (batting_stats, pitching_stats) = await asyncio.gather( player_service.get_player(player.id), stats_service.get_player_stats(player.id, search_season), diff --git a/commands/profile/images.py b/commands/profile/images.py index ae89e20..736dc00 100644 --- a/commands/profile/images.py +++ b/commands/profile/images.py @@ -4,6 +4,7 @@ Player Image Management Commands Allows users to update player fancy card and headshot images for players on teams they own. Admins can update any player's images. """ + from typing import List, Tuple import asyncio import aiohttp @@ -15,15 +16,16 @@ from discord.ext import commands from config import get_config from services.player_service import player_service from services.team_service import team_service +from utils.autocomplete import player_autocomplete from utils.logging import get_contextual_logger from utils.decorators import logged_command from views.embeds import EmbedColors, EmbedTemplate from views.base import BaseView from models.player import Player - # URL Validation Functions + def validate_url_format(url: str) -> Tuple[bool, str]: """ Validate URL format for image links. @@ -40,17 +42,20 @@ def validate_url_format(url: str) -> Tuple[bool, str]: return False, "URL too long (max 500 characters)" # Protocol check - if not url.startswith(('http://', 'https://')): + if not url.startswith(("http://", "https://")): return False, "URL must start with http:// or https://" # Image extension check - valid_extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp') + valid_extensions = (".jpg", ".jpeg", ".png", ".gif", ".webp") url_lower = url.lower() # Check if URL ends with valid extension (ignore query params) - base_url = url_lower.split('?')[0] # Remove query parameters + base_url = url_lower.split("?")[0] # Remove query parameters if not any(base_url.endswith(ext) for ext in valid_extensions): - return False, f"URL must end with a valid image extension: {', '.join(valid_extensions)}" + return ( + False, + f"URL must end with a valid image extension: {', '.join(valid_extensions)}", + ) return True, "" @@ -68,14 +73,19 @@ async def check_url_accessibility(url: str) -> Tuple[bool, str]: """ try: async with aiohttp.ClientSession() as session: - async with session.head(url, timeout=aiohttp.ClientTimeout(total=5)) as response: + async with session.head( + url, timeout=aiohttp.ClientTimeout(total=5) + ) as response: if response.status != 200: return False, f"URL returned status {response.status}" # Check content-type header - content_type = response.headers.get('content-type', '').lower() - if content_type and not content_type.startswith('image/'): - return False, f"URL does not return an image (content-type: {content_type})" + content_type = response.headers.get("content-type", "").lower() + if content_type and not content_type.startswith("image/"): + return ( + False, + f"URL does not return an image (content-type: {content_type})", + ) return True, "" @@ -89,11 +99,9 @@ async def check_url_accessibility(url: str) -> Tuple[bool, str]: # Permission Checking + async def can_edit_player_image( - interaction: discord.Interaction, - player: Player, - season: int, - logger + interaction: discord.Interaction, player: Player, season: int, logger ) -> Tuple[bool, str]: """ Check if user can edit player's image. @@ -130,7 +138,7 @@ async def can_edit_player_image( "User owns organization, granting permission", user_id=interaction.user.id, user_team=user_team.abbrev, - player_team=player.team.abbrev + player_team=player.team.abbrev, ) return True, "" @@ -141,6 +149,7 @@ async def can_edit_player_image( # Confirmation View + class ImageUpdateConfirmView(BaseView): """Confirmation view showing image preview before updating.""" @@ -151,27 +160,33 @@ class ImageUpdateConfirmView(BaseView): self.image_type = image_type self.confirmed = False - @discord.ui.button(label="Confirm Update", style=discord.ButtonStyle.success, emoji="āœ…") - async def confirm_button(self, interaction: discord.Interaction, button: discord.ui.Button): + @discord.ui.button( + label="Confirm Update", style=discord.ButtonStyle.success, emoji="āœ…" + ) + async def confirm_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Confirm the image update.""" self.confirmed = True # Disable all buttons for item in self.children: - if hasattr(item, 'disabled'): + if hasattr(item, "disabled"): item.disabled = True # type: ignore await interaction.response.edit_message(view=self) self.stop() @discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary, emoji="āŒ") - async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button): + async def cancel_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Cancel the image update.""" self.confirmed = False # Disable all buttons for item in self.children: - if hasattr(item, 'disabled'): + if hasattr(item, "disabled"): item.disabled = True # type: ignore await interaction.response.edit_message(view=self) @@ -180,6 +195,7 @@ class ImageUpdateConfirmView(BaseView): # Autocomplete + async def player_name_autocomplete( interaction: discord.Interaction, current: str, @@ -190,7 +206,6 @@ async def player_name_autocomplete( try: # Use the shared autocomplete utility with team prioritization - from utils.autocomplete import player_autocomplete return await player_autocomplete(interaction, current) except Exception: # Return empty list on error to avoid breaking autocomplete @@ -199,27 +214,29 @@ async def player_name_autocomplete( # Main Command Cog + class ImageCommands(commands.Cog): """Player image management command handlers.""" def __init__(self, bot: commands.Bot): self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.ImageCommands') + self.logger = get_contextual_logger(f"{__name__}.ImageCommands") self.logger.info("ImageCommands cog initialized") @app_commands.command( - name="set-image", - description="Update a player's fancy card or headshot image" + name="set-image", description="Update a player's fancy card or headshot image" ) @app_commands.describe( image_type="Type of image to update", player_name="Player name (use autocomplete)", - image_url="Direct URL to the image file" + image_url="Direct URL to the image file", + ) + @app_commands.choices( + image_type=[ + app_commands.Choice(name="Fancy Card", value="fancy-card"), + app_commands.Choice(name="Headshot", value="headshot"), + ] ) - @app_commands.choices(image_type=[ - app_commands.Choice(name="Fancy Card", value="fancy-card"), - app_commands.Choice(name="Headshot", value="headshot") - ]) @app_commands.autocomplete(player_name=player_name_autocomplete) @logged_command("/set-image") async def set_image( @@ -227,7 +244,7 @@ class ImageCommands(commands.Cog): interaction: discord.Interaction, image_type: app_commands.Choice[str], player_name: str, - image_url: str + image_url: str, ): """Update a player's image (fancy card or headshot).""" # Defer response for potentially slow operations @@ -242,7 +259,7 @@ class ImageCommands(commands.Cog): "Image update requested", user_id=interaction.user.id, player_name=player_name, - image_type=img_type + image_type=img_type, ) # Step 1: Validate URL format @@ -252,10 +269,10 @@ class ImageCommands(commands.Cog): embed = EmbedTemplate.error( title="Invalid URL Format", description=f"āŒ {format_error}\n\n" - f"**Requirements:**\n" - f"• Must start with `http://` or `https://`\n" - f"• Must end with `.jpg`, `.jpeg`, `.png`, `.gif`, or `.webp`\n" - f"• Maximum 500 characters" + f"**Requirements:**\n" + f"• Must start with `http://` or `https://`\n" + f"• Must end with `.jpg`, `.jpeg`, `.png`, `.gif`, or `.webp`\n" + f"• Maximum 500 characters", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -268,24 +285,26 @@ class ImageCommands(commands.Cog): embed = EmbedTemplate.error( title="URL Not Accessible", description=f"āŒ {access_error}\n\n" - f"**Please check:**\n" - f"• URL is correct and not expired\n" - f"• Image host is online\n" - f"• URL points directly to an image file\n" - f"• URL is publicly accessible" + f"**Please check:**\n" + f"• URL is correct and not expired\n" + f"• Image host is online\n" + f"• URL points directly to an image file\n" + f"• URL is publicly accessible", ) await interaction.followup.send(embed=embed, ephemeral=True) return # Step 3: Find player self.logger.debug("Searching for player", player_name=player_name) - players = await player_service.get_players_by_name(player_name, get_config().sba_season) + players = await player_service.get_players_by_name( + player_name, get_config().sba_season + ) if not players: self.logger.warning("Player not found", player_name=player_name) embed = EmbedTemplate.error( title="Player Not Found", - description=f"āŒ No player found matching `{player_name}` in the current season." + description=f"āŒ No player found matching `{player_name}` in the current season.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -303,11 +322,13 @@ class ImageCommands(commands.Cog): if player is None: # Multiple candidates, ask user to be more specific - player_list = "\n".join([f"• {p.name} ({p.primary_position})" for p in players[:10]]) + player_list = "\n".join( + [f"• {p.name} ({p.primary_position})" for p in players[:10]] + ) embed = EmbedTemplate.info( title="Multiple Players Found", description=f"šŸ” Multiple players match `{player_name}`:\n\n{player_list}\n\n" - f"Please use the exact name from autocomplete." + f"Please use the exact name from autocomplete.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -324,12 +345,12 @@ class ImageCommands(commands.Cog): "Permission denied", user_id=interaction.user.id, player_id=player.id, - error=permission_error + error=permission_error, ) embed = EmbedTemplate.error( title="Permission Denied", description=f"āŒ {permission_error}\n\n" - f"You can only update images for players on teams you own." + f"You can only update images for players on teams you own.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -339,52 +360,46 @@ class ImageCommands(commands.Cog): preview_embed = EmbedTemplate.create_base_embed( title=f"šŸ–¼ļø Update {display_name} for {player.name}", description=f"Preview the new {display_name.lower()} below. Click **Confirm Update** to save this change.", - color=EmbedColors.INFO + color=EmbedColors.INFO, ) # Add current image info current_image = getattr(player, field_name, None) if current_image: preview_embed.add_field( - name="Current Image", - value="Will be replaced", - inline=True + name="Current Image", value="Will be replaced", inline=True ) else: - preview_embed.add_field( - name="Current Image", - value="None set", - inline=True - ) + preview_embed.add_field(name="Current Image", value="None set", inline=True) # Add player info preview_embed.add_field( name="Player", value=f"{player.name} ({player.primary_position})", - inline=True + inline=True, ) - if hasattr(player, 'team') and player.team: - preview_embed.add_field( - name="Team", - value=player.team.abbrev, - inline=True - ) + if hasattr(player, "team") and player.team: + preview_embed.add_field(name="Team", value=player.team.abbrev, inline=True) # Set the new image as thumbnail for preview preview_embed.set_thumbnail(url=image_url) - preview_embed.set_footer(text="This preview shows how the image will appear. Confirm to save.") + preview_embed.set_footer( + text="This preview shows how the image will appear. Confirm to save." + ) # Create confirmation view confirm_view = ImageUpdateConfirmView( player=player, image_url=image_url, image_type=img_type, - user_id=interaction.user.id + user_id=interaction.user.id, ) - await interaction.followup.send(embed=preview_embed, view=confirm_view, ephemeral=True) + await interaction.followup.send( + embed=preview_embed, view=confirm_view, ephemeral=True + ) # Wait for confirmation await confirm_view.wait() @@ -393,7 +408,7 @@ class ImageCommands(commands.Cog): self.logger.info("Image update cancelled by user", player_id=player.id) cancelled_embed = EmbedTemplate.info( title="Update Cancelled", - description=f"No changes were made to {player.name}'s {display_name.lower()}." + description=f"No changes were made to {player.name}'s {display_name.lower()}.", ) await interaction.edit_original_response(embed=cancelled_embed, view=None) return @@ -403,7 +418,7 @@ class ImageCommands(commands.Cog): "Updating player image", player_id=player.id, field=field_name, - url_length=len(image_url) + url_length=len(image_url), ) update_data = {field_name: image_url} @@ -413,7 +428,7 @@ class ImageCommands(commands.Cog): self.logger.error("Failed to update player", player_id=player.id) error_embed = EmbedTemplate.error( title="Update Failed", - description="āŒ An error occurred while updating the player's image. Please try again." + description="āŒ An error occurred while updating the player's image. Please try again.", ) await interaction.edit_original_response(embed=error_embed, view=None) return @@ -423,32 +438,24 @@ class ImageCommands(commands.Cog): "Player image updated successfully", player_id=player.id, field=field_name, - user_id=interaction.user.id + user_id=interaction.user.id, ) success_embed = EmbedTemplate.success( title="Image Updated Successfully!", - description=f"**{display_name}** for **{player.name}** has been updated." + description=f"**{display_name}** for **{player.name}** has been updated.", ) success_embed.add_field( name="Player", value=f"{player.name} ({player.primary_position})", - inline=True + inline=True, ) - if hasattr(player, 'team') and player.team: - success_embed.add_field( - name="Team", - value=player.team.abbrev, - inline=True - ) + if hasattr(player, "team") and player.team: + success_embed.add_field(name="Team", value=player.team.abbrev, inline=True) - success_embed.add_field( - name="Image Type", - value=display_name, - inline=True - ) + success_embed.add_field(name="Image Type", value=display_name, inline=True) # Show the new image success_embed.set_thumbnail(url=image_url) diff --git a/commands/soak/info.py b/commands/soak/info.py index 7158778..3dafd7f 100644 --- a/commands/soak/info.py +++ b/commands/soak/info.py @@ -3,6 +3,9 @@ Soak Info Commands Provides information about soak mentions without triggering the easter egg. """ + +from datetime import datetime + import discord from discord import app_commands from discord.ext import commands @@ -19,11 +22,13 @@ class SoakInfoCommands(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.SoakInfoCommands') + self.logger = get_contextual_logger(f"{__name__}.SoakInfoCommands") self.tracker = SoakTracker() self.logger.info("SoakInfoCommands cog initialized") - @app_commands.command(name="lastsoak", description="Get information about the last soak mention") + @app_commands.command( + name="lastsoak", description="Get information about the last soak mention" + ) @logged_command("/lastsoak") async def last_soak(self, interaction: discord.Interaction): """Show information about the last soak mention.""" @@ -35,13 +40,9 @@ class SoakInfoCommands(commands.Cog): if not last_soak: embed = EmbedTemplate.info( title="Last Soak", - description="No one has said the forbidden word yet. 🤫" - ) - embed.add_field( - name="Total Mentions", - value="0", - inline=False + description="No one has said the forbidden word yet. 🤫", ) + embed.add_field(name="Total Mentions", value="0", inline=False) await interaction.followup.send(embed=embed) return @@ -50,23 +51,24 @@ class SoakInfoCommands(commands.Cog): total_count = self.tracker.get_soak_count() # Determine disappointment tier - tier_key = get_tier_for_seconds(int(time_since.total_seconds()) if time_since else None) + tier_key = get_tier_for_seconds( + int(time_since.total_seconds()) if time_since else None + ) tier_description = get_tier_description(tier_key) # Create embed embed = EmbedTemplate.create_base_embed( title="šŸ“Š Last Soak", description="Information about the most recent soak mention", - color=EmbedColors.INFO + color=EmbedColors.INFO, ) # Parse timestamp for Discord formatting try: - from datetime import datetime timestamp_str = last_soak["timestamp"] - if timestamp_str.endswith('Z'): - timestamp_str = timestamp_str[:-1] + '+00:00' - timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + if timestamp_str.endswith("Z"): + timestamp_str = timestamp_str[:-1] + "+00:00" + timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) unix_timestamp = int(timestamp.timestamp()) # Add relative time with warning if very recent @@ -74,54 +76,44 @@ class SoakInfoCommands(commands.Cog): if time_since and time_since.total_seconds() < 1800: # Less than 30 minutes time_field_value += "\n\n😤 Way too soon!" - embed.add_field( - name="Last Mentioned", - value=time_field_value, - inline=False - ) + embed.add_field(name="Last Mentioned", value=time_field_value, inline=False) except Exception as e: self.logger.error(f"Error parsing timestamp: {e}") embed.add_field( - name="Last Mentioned", - value="Error parsing timestamp", - inline=False + name="Last Mentioned", value="Error parsing timestamp", inline=False ) # Add user info user_mention = f"<@{last_soak['user_id']}>" - display_name = last_soak.get('display_name', last_soak.get('username', 'Unknown')) + display_name = last_soak.get( + "display_name", last_soak.get("username", "Unknown") + ) embed.add_field( - name="By", - value=f"{user_mention} ({display_name})", - inline=True + name="By", value=f"{user_mention} ({display_name})", inline=True ) # Add message link try: guild_id = interaction.guild_id - channel_id = last_soak['channel_id'] - message_id = last_soak['message_id'] - jump_url = f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}" + channel_id = last_soak["channel_id"] + message_id = last_soak["message_id"] + jump_url = ( + f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}" + ) embed.add_field( - name="Message", - value=f"[Jump to message]({jump_url})", - inline=True + name="Message", value=f"[Jump to message]({jump_url})", inline=True ) except Exception as e: self.logger.error(f"Error creating jump URL: {e}") # Add total count - embed.add_field( - name="Total Mentions", - value=str(total_count), - inline=True - ) + embed.add_field(name="Total Mentions", value=str(total_count), inline=True) # Add disappointment level embed.add_field( name="Disappointment Level", value=f"{tier_key.replace('_', ' ').title()}: {tier_description}", - inline=False + inline=False, ) await interaction.followup.send(embed=embed) diff --git a/services/draft_service.py b/services/draft_service.py index 649b70d..b6962fc 100644 --- a/services/draft_service.py +++ b/services/draft_service.py @@ -8,7 +8,9 @@ import logging from typing import Optional, Dict, Any from datetime import UTC, datetime, timedelta +from config import get_config from services.base_service import BaseService +from services.draft_pick_service import draft_pick_service from models.draft_data import DraftData logger = logging.getLogger(f"{__name__}.DraftService") @@ -162,9 +164,6 @@ class DraftService(BaseService[DraftData]): Updated DraftData with new currentpick """ try: - from services.draft_pick_service import draft_pick_service - from config import get_config - config = get_config() season = config.sba_season total_picks = config.draft_total_picks diff --git a/services/roster_service.py b/services/roster_service.py index 9ee7648..d58c206 100644 --- a/services/roster_service.py +++ b/services/roster_service.py @@ -7,6 +7,7 @@ Handles roster operations and validation. import logging from typing import Optional, List, Dict +from api.client import get_global_client from models.roster import TeamRoster from models.player import Player from models.transaction import RosterValidation @@ -20,8 +21,6 @@ class RosterService: def __init__(self): """Initialize roster service.""" - from api.client import get_global_client - self._get_client = get_global_client logger.debug("RosterService initialized") diff --git a/services/schedule_service.py b/services/schedule_service.py index c537239..78ee51d 100644 --- a/services/schedule_service.py +++ b/services/schedule_service.py @@ -3,65 +3,63 @@ Schedule service for Discord Bot v2.0 Handles game schedule and results retrieval and processing. """ + import logging from typing import Optional, List, Dict, Tuple +from api.client import get_global_client from models.game import Game -logger = logging.getLogger(f'{__name__}.ScheduleService') +logger = logging.getLogger(f"{__name__}.ScheduleService") class ScheduleService: """ Service for schedule and game operations. - + Features: - Weekly schedule retrieval - Team-specific schedules - Game results and upcoming games - Series organization """ - + def __init__(self): """Initialize schedule service.""" - from api.client import get_global_client self._get_client = get_global_client logger.debug("ScheduleService initialized") - + async def get_client(self): """Get the API client.""" return await self._get_client() - + async def get_week_schedule(self, season: int, week: int) -> List[Game]: """ Get all games for a specific week. - + Args: season: Season number week: Week number - + Returns: List of Game instances for the week """ try: client = await self.get_client() - - params = [ - ('season', str(season)), - ('week', str(week)) - ] - - response = await client.get('games', params=params) - - if not response or 'games' not in response: + + params = [("season", str(season)), ("week", str(week))] + + response = await client.get("games", params=params) + + if not response or "games" not in response: logger.warning(f"No games data found for season {season}, week {week}") return [] - - games_list = response['games'] + + games_list = response["games"] if not games_list: logger.warning(f"Empty games list for season {season}, week {week}") return [] - + # Convert to Game objects games = [] for game_data in games_list: @@ -71,185 +69,206 @@ class ScheduleService: except Exception as e: logger.error(f"Error parsing game data: {e}") continue - - logger.info(f"Retrieved {len(games)} games for season {season}, week {week}") + + logger.info( + f"Retrieved {len(games)} games for season {season}, week {week}" + ) return games - + except Exception as e: - logger.error(f"Error getting week schedule for season {season}, week {week}: {e}") + logger.error( + f"Error getting week schedule for season {season}, week {week}: {e}" + ) return [] - - async def get_team_schedule(self, season: int, team_abbrev: str, weeks: Optional[int] = None) -> List[Game]: + + async def get_team_schedule( + self, season: int, team_abbrev: str, weeks: Optional[int] = None + ) -> List[Game]: """ Get schedule for a specific team. - + Args: season: Season number team_abbrev: Team abbreviation (e.g., 'NYY') weeks: Number of weeks to retrieve (None for all weeks) - + Returns: List of Game instances for the team """ try: team_games = [] team_abbrev_upper = team_abbrev.upper() - + # If weeks not specified, try a reasonable range (18 weeks typical) week_range = range(1, (weeks + 1) if weeks else 19) - + for week in week_range: week_games = await self.get_week_schedule(season, week) - + # Filter games involving this team for game in week_games: - if (game.away_team.abbrev.upper() == team_abbrev_upper or - game.home_team.abbrev.upper() == team_abbrev_upper): + if ( + game.away_team.abbrev.upper() == team_abbrev_upper + or game.home_team.abbrev.upper() == team_abbrev_upper + ): team_games.append(game) - + logger.info(f"Retrieved {len(team_games)} games for team {team_abbrev}") return team_games - + except Exception as e: logger.error(f"Error getting team schedule for {team_abbrev}: {e}") return [] - + async def get_recent_games(self, season: int, weeks_back: int = 2) -> List[Game]: """ Get recently completed games. - + Args: season: Season number weeks_back: Number of weeks back to look - + Returns: List of completed Game instances """ try: recent_games = [] - + # Get games from recent weeks for week_offset in range(weeks_back): # This is simplified - in production you'd want to determine current week week = 10 - week_offset # Assuming we're around week 10 if week <= 0: break - + week_games = await self.get_week_schedule(season, week) - + # Only include completed games completed_games = [game for game in week_games if game.is_completed] recent_games.extend(completed_games) - + # Sort by week descending (most recent first) recent_games.sort(key=lambda x: (x.week, x.game_num or 0), reverse=True) - + logger.debug(f"Retrieved {len(recent_games)} recent games") return recent_games - + except Exception as e: logger.error(f"Error getting recent games: {e}") return [] - + async def get_upcoming_games(self, season: int, weeks_ahead: int = 6) -> List[Game]: """ Get upcoming scheduled games by scanning multiple weeks. - + Args: season: Season number weeks_ahead: Number of weeks to scan ahead (default 6) - + Returns: List of upcoming Game instances """ try: upcoming_games = [] - + # Scan through weeks to find games without scores for week in range(1, 19): # Standard season length week_games = await self.get_week_schedule(season, week) - + # Find games without scores (not yet played) - upcoming_games_week = [game for game in week_games if not game.is_completed] + upcoming_games_week = [ + game for game in week_games if not game.is_completed + ] upcoming_games.extend(upcoming_games_week) - + # If we found upcoming games, we can limit how many more weeks to check if upcoming_games and len(upcoming_games) >= 20: # Reasonable limit break - + # Sort by week, then game number upcoming_games.sort(key=lambda x: (x.week, x.game_num or 0)) - + logger.debug(f"Retrieved {len(upcoming_games)} upcoming games") return upcoming_games - + except Exception as e: logger.error(f"Error getting upcoming games: {e}") return [] - - async def get_series_by_teams(self, season: int, week: int, team1_abbrev: str, team2_abbrev: str) -> List[Game]: + + async def get_series_by_teams( + self, season: int, week: int, team1_abbrev: str, team2_abbrev: str + ) -> List[Game]: """ Get all games in a series between two teams for a specific week. - + Args: season: Season number week: Week number team1_abbrev: First team abbreviation team2_abbrev: Second team abbreviation - + Returns: List of Game instances in the series """ try: week_games = await self.get_week_schedule(season, week) - + team1_upper = team1_abbrev.upper() team2_upper = team2_abbrev.upper() - + # Find games between these two teams series_games = [] for game in week_games: - game_teams = {game.away_team.abbrev.upper(), game.home_team.abbrev.upper()} + game_teams = { + game.away_team.abbrev.upper(), + game.home_team.abbrev.upper(), + } if game_teams == {team1_upper, team2_upper}: series_games.append(game) - + # Sort by game number series_games.sort(key=lambda x: x.game_num or 0) - - logger.debug(f"Retrieved {len(series_games)} games in series between {team1_abbrev} and {team2_abbrev}") + + logger.debug( + f"Retrieved {len(series_games)} games in series between {team1_abbrev} and {team2_abbrev}" + ) return series_games - + except Exception as e: - logger.error(f"Error getting series between {team1_abbrev} and {team2_abbrev}: {e}") + logger.error( + f"Error getting series between {team1_abbrev} and {team2_abbrev}: {e}" + ) return [] - - def group_games_by_series(self, games: List[Game]) -> Dict[Tuple[str, str], List[Game]]: + + def group_games_by_series( + self, games: List[Game] + ) -> Dict[Tuple[str, str], List[Game]]: """ Group games by matchup (series). - + Args: games: List of Game instances - + Returns: Dictionary mapping (team1, team2) tuples to game lists """ series_games = {} - + for game in games: # Create consistent team pairing (alphabetical order) teams = sorted([game.away_team.abbrev, game.home_team.abbrev]) series_key = (teams[0], teams[1]) - + if series_key not in series_games: series_games[series_key] = [] series_games[series_key].append(game) - + # Sort each series by game number for series_key in series_games: series_games[series_key].sort(key=lambda x: x.game_num or 0) - + return series_games # Global service instance -schedule_service = ScheduleService() \ No newline at end of file +schedule_service = ScheduleService() diff --git a/services/sheets_service.py b/services/sheets_service.py index a0b313e..0e83359 100644 --- a/services/sheets_service.py +++ b/services/sheets_service.py @@ -8,6 +8,7 @@ import asyncio from typing import Dict, List, Any, Optional import pygsheets +from config import get_config from utils.logging import get_contextual_logger from exceptions import SheetsException @@ -24,8 +25,6 @@ class SheetsService: If None, will use path from config """ if credentials_path is None: - from config import get_config - credentials_path = get_config().sheets_credentials_path self.credentials_path = credentials_path diff --git a/services/standings_service.py b/services/standings_service.py index af3b164..4240c61 100644 --- a/services/standings_service.py +++ b/services/standings_service.py @@ -3,61 +3,62 @@ Standings service for Discord Bot v2.0 Handles team standings retrieval and processing. """ + import logging from typing import Optional, List, Dict +from api.client import get_global_client from models.standings import TeamStandings from exceptions import APIException -logger = logging.getLogger(f'{__name__}.StandingsService') +logger = logging.getLogger(f"{__name__}.StandingsService") class StandingsService: """ Service for team standings operations. - + Features: - League standings retrieval - Division-based filtering - Season-specific data - Playoff positioning """ - + def __init__(self): """Initialize standings service.""" - from api.client import get_global_client self._get_client = get_global_client logger.debug("StandingsService initialized") - + async def get_client(self): """Get the API client.""" return await self._get_client() - + async def get_league_standings(self, season: int) -> List[TeamStandings]: """ Get complete league standings for a season. - + Args: season: Season number - + Returns: List of TeamStandings ordered by record """ try: client = await self.get_client() - - params = [('season', str(season))] - response = await client.get('standings', params=params) - - if not response or 'standings' not in response: + + params = [("season", str(season))] + response = await client.get("standings", params=params) + + if not response or "standings" not in response: logger.warning(f"No standings data found for season {season}") return [] - - standings_list = response['standings'] + + standings_list = response["standings"] if not standings_list: logger.warning(f"Empty standings for season {season}") return [] - + # Convert to model objects standings = [] for standings_data in standings_list: @@ -67,34 +68,41 @@ class StandingsService: except Exception as e: logger.error(f"Error parsing standings data for team: {e}") continue - - logger.info(f"Retrieved standings for {len(standings)} teams in season {season}") + + logger.info( + f"Retrieved standings for {len(standings)} teams in season {season}" + ) return standings - + except Exception as e: logger.error(f"Error getting league standings for season {season}: {e}") return [] - - async def get_standings_by_division(self, season: int) -> Dict[str, List[TeamStandings]]: + + async def get_standings_by_division( + self, season: int + ) -> Dict[str, List[TeamStandings]]: """ Get standings grouped by division. - + Args: season: Season number - + Returns: Dictionary mapping division names to team standings """ try: all_standings = await self.get_league_standings(season) - + if not all_standings: return {} - + # Group by division divisions = {} for team_standings in all_standings: - if hasattr(team_standings.team, 'division') and team_standings.team.division: + if ( + hasattr(team_standings.team, "division") + and team_standings.team.division + ): div_name = team_standings.team.division.division_name if div_name not in divisions: divisions[div_name] = [] @@ -104,95 +112,99 @@ class StandingsService: if "No Division" not in divisions: divisions["No Division"] = [] divisions["No Division"].append(team_standings) - + # Sort each division by record (wins descending, then by winning percentage) for div_name in divisions: divisions[div_name].sort( - key=lambda x: (x.wins, x.winning_percentage), - reverse=True + key=lambda x: (x.wins, x.winning_percentage), reverse=True ) - + logger.debug(f"Grouped standings into {len(divisions)} divisions") return divisions - + except Exception as e: logger.error(f"Error grouping standings by division: {e}") return {} - - async def get_team_standings(self, team_abbrev: str, season: int) -> Optional[TeamStandings]: + + async def get_team_standings( + self, team_abbrev: str, season: int + ) -> Optional[TeamStandings]: """ Get standings for a specific team. - + Args: team_abbrev: Team abbreviation (e.g., 'NYY') season: Season number - + Returns: TeamStandings instance or None if not found """ try: all_standings = await self.get_league_standings(season) - + # Find team by abbreviation team_abbrev_upper = team_abbrev.upper() for team_standings in all_standings: if team_standings.team.abbrev.upper() == team_abbrev_upper: logger.debug(f"Found standings for {team_abbrev}: {team_standings}") return team_standings - - logger.warning(f"No standings found for team {team_abbrev} in season {season}") + + logger.warning( + f"No standings found for team {team_abbrev} in season {season}" + ) return None - + except Exception as e: logger.error(f"Error getting standings for team {team_abbrev}: {e}") return None - + async def get_playoff_picture(self, season: int) -> Dict[str, List[TeamStandings]]: """ Get playoff picture with division leaders and wild card contenders. - + Args: season: Season number - + Returns: Dictionary with 'division_leaders' and 'wild_card' lists """ try: divisions = await self.get_standings_by_division(season) - + if not divisions: return {"division_leaders": [], "wild_card": []} - + # Get division leaders (first place in each division) division_leaders = [] wild_card_candidates = [] - + for div_name, teams in divisions.items(): if teams: # Division has teams # First team is division leader division_leaders.append(teams[0]) - + # Rest are potential wild card candidates for team in teams[1:]: wild_card_candidates.append(team) - + # Sort wild card candidates by record wild_card_candidates.sort( - key=lambda x: (x.wins, x.winning_percentage), - reverse=True + key=lambda x: (x.wins, x.winning_percentage), reverse=True ) - + # Take top wild card contenders (typically top 6-8 teams) wild_card_contenders = wild_card_candidates[:8] - - logger.debug(f"Playoff picture: {len(division_leaders)} division leaders, " - f"{len(wild_card_contenders)} wild card contenders") - + + logger.debug( + f"Playoff picture: {len(division_leaders)} division leaders, " + f"{len(wild_card_contenders)} wild card contenders" + ) + return { "division_leaders": division_leaders, - "wild_card": wild_card_contenders + "wild_card": wild_card_contenders, } - + except Exception as e: logger.error(f"Error generating playoff picture: {e}") return {"division_leaders": [], "wild_card": []} @@ -217,9 +229,7 @@ class StandingsService: # Use 8 second timeout for this potentially slow operation response = await client.post( - f'standings/s{season}/recalculate', - {}, - timeout=8.0 + f"standings/s{season}/recalculate", {}, timeout=8.0 ) logger.info(f"Recalculated standings for season {season}") @@ -231,4 +241,4 @@ class StandingsService: # Global service instance -standings_service = StandingsService() \ No newline at end of file +standings_service = StandingsService() diff --git a/services/stats_service.py b/services/stats_service.py index 323c956..a3b3a06 100644 --- a/services/stats_service.py +++ b/services/stats_service.py @@ -3,129 +3,142 @@ Statistics service for Discord Bot v2.0 Handles batting and pitching statistics retrieval and processing. """ + import logging from typing import Optional +from api.client import get_global_client from models.batting_stats import BattingStats from models.pitching_stats import PitchingStats -logger = logging.getLogger(f'{__name__}.StatsService') +logger = logging.getLogger(f"{__name__}.StatsService") class StatsService: """ Service for player statistics operations. - + Features: - Batting statistics retrieval - Pitching statistics retrieval - Season-specific filtering - Error handling and logging """ - + def __init__(self): """Initialize stats service.""" # We don't inherit from BaseService since we need custom endpoints - from api.client import get_global_client self._get_client = get_global_client logger.debug("StatsService initialized") - + async def get_client(self): """Get the API client.""" return await self._get_client() - - async def get_batting_stats(self, player_id: int, season: int) -> Optional[BattingStats]: + + async def get_batting_stats( + self, player_id: int, season: int + ) -> Optional[BattingStats]: """ Get batting statistics for a player in a specific season. - + Args: player_id: Player ID season: Season number - + Returns: BattingStats instance or None if not found """ try: client = await self.get_client() - + # Call the batting stats view endpoint - params = [ - ('player_id', str(player_id)), - ('season', str(season)) - ] - - response = await client.get('views/season-stats/batting', params=params) - - if not response or 'stats' not in response: - logger.debug(f"No batting stats found for player {player_id}, season {season}") + params = [("player_id", str(player_id)), ("season", str(season))] + + response = await client.get("views/season-stats/batting", params=params) + + if not response or "stats" not in response: + logger.debug( + f"No batting stats found for player {player_id}, season {season}" + ) return None - - stats_list = response['stats'] + + stats_list = response["stats"] if not stats_list: - logger.debug(f"Empty batting stats for player {player_id}, season {season}") + logger.debug( + f"Empty batting stats for player {player_id}, season {season}" + ) return None - + # Take the first (should be only) result stats_data = stats_list[0] - + batting_stats = BattingStats.from_api_data(stats_data) - logger.debug(f"Retrieved batting stats for player {player_id}: {batting_stats.avg:.3f} AVG") + logger.debug( + f"Retrieved batting stats for player {player_id}: {batting_stats.avg:.3f} AVG" + ) return batting_stats - + except Exception as e: logger.error(f"Error getting batting stats for player {player_id}: {e}") return None - - async def get_pitching_stats(self, player_id: int, season: int) -> Optional[PitchingStats]: + + async def get_pitching_stats( + self, player_id: int, season: int + ) -> Optional[PitchingStats]: """ Get pitching statistics for a player in a specific season. - + Args: player_id: Player ID season: Season number - + Returns: PitchingStats instance or None if not found """ try: client = await self.get_client() - + # Call the pitching stats view endpoint - params = [ - ('player_id', str(player_id)), - ('season', str(season)) - ] - - response = await client.get('views/season-stats/pitching', params=params) - - if not response or 'stats' not in response: - logger.debug(f"No pitching stats found for player {player_id}, season {season}") + params = [("player_id", str(player_id)), ("season", str(season))] + + response = await client.get("views/season-stats/pitching", params=params) + + if not response or "stats" not in response: + logger.debug( + f"No pitching stats found for player {player_id}, season {season}" + ) return None - - stats_list = response['stats'] + + stats_list = response["stats"] if not stats_list: - logger.debug(f"Empty pitching stats for player {player_id}, season {season}") + logger.debug( + f"Empty pitching stats for player {player_id}, season {season}" + ) return None - + # Take the first (should be only) result stats_data = stats_list[0] - + pitching_stats = PitchingStats.from_api_data(stats_data) - logger.debug(f"Retrieved pitching stats for player {player_id}: {pitching_stats.era:.2f} ERA") + logger.debug( + f"Retrieved pitching stats for player {player_id}: {pitching_stats.era:.2f} ERA" + ) return pitching_stats - + except Exception as e: logger.error(f"Error getting pitching stats for player {player_id}: {e}") return None - - async def get_player_stats(self, player_id: int, season: int) -> tuple[Optional[BattingStats], Optional[PitchingStats]]: + + async def get_player_stats( + self, player_id: int, season: int + ) -> tuple[Optional[BattingStats], Optional[PitchingStats]]: """ Get both batting and pitching statistics for a player. - + Args: player_id: Player ID season: Season number - + Returns: Tuple of (batting_stats, pitching_stats) - either can be None """ @@ -133,20 +146,22 @@ class StatsService: # Get both types of stats concurrently batting_task = self.get_batting_stats(player_id, season) pitching_task = self.get_pitching_stats(player_id, season) - + batting_stats = await batting_task pitching_stats = await pitching_task - - logger.debug(f"Retrieved stats for player {player_id}: " - f"batting={'yes' if batting_stats else 'no'}, " - f"pitching={'yes' if pitching_stats else 'no'}") - + + logger.debug( + f"Retrieved stats for player {player_id}: " + f"batting={'yes' if batting_stats else 'no'}, " + f"pitching={'yes' if pitching_stats else 'no'}" + ) + return batting_stats, pitching_stats - + except Exception as e: logger.error(f"Error getting player stats for {player_id}: {e}") return None, None # Global service instance -stats_service = StatsService() \ No newline at end of file +stats_service = StatsService() diff --git a/tasks/draft_monitor.py b/tasks/draft_monitor.py index 88b3cfc..a82b891 100644 --- a/tasks/draft_monitor.py +++ b/tasks/draft_monitor.py @@ -14,10 +14,17 @@ 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.league_service import league_service +from services.player_service import player_service from services.roster_service import roster_service +from services.team_service import team_service +from utils.draft_helpers import validate_cap_space from utils.logging import get_contextual_logger from utils.helpers import get_team_salary_cap -from views.draft_views import create_on_clock_announcement_embed +from views.draft_views import ( + create_on_clock_announcement_embed, + create_player_draft_card, +) from config import get_config @@ -303,9 +310,6 @@ class DraftMonitorTask: True if draft succeeded """ try: - from utils.draft_helpers import validate_cap_space - from services.team_service import team_service - # Get team roster for cap validation roster = await team_service.get_team_roster(draft_pick.owner.id, "current") @@ -337,9 +341,6 @@ class DraftMonitorTask: return False # Get current league state for dem_week calculation - from services.player_service import player_service - from services.league_service import league_service - current = await league_service.get_current_state() # Update player team with dem_week set to current.week + 2 for draft picks @@ -366,8 +367,6 @@ class DraftMonitorTask: 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) diff --git a/tests/test_services_draft.py b/tests/test_services_draft.py index 4270604..e94c214 100644 --- a/tests/test_services_draft.py +++ b/tests/test_services_draft.py @@ -364,7 +364,7 @@ class TestDraftService: # Mock draft_pick_service at the module level with patch( - "services.draft_pick_service.draft_pick_service" + "services.draft_service.draft_pick_service" ) as mock_pick_service: unfilled_pick = DraftPick( **create_draft_pick_data( @@ -402,7 +402,7 @@ class TestDraftService: mock_config.return_value = config with patch( - "services.draft_pick_service.draft_pick_service" + "services.draft_service.draft_pick_service" ) as mock_pick_service: # Picks 26-28 are filled, 29 is empty async def get_pick_side_effect(season, overall): diff --git a/tests/test_views_injury_modals.py b/tests/test_views_injury_modals.py index ccc3f9a..c6c66fe 100644 --- a/tests/test_views_injury_modals.py +++ b/tests/test_views_injury_modals.py @@ -4,6 +4,7 @@ Tests for Injury Modal Validation in Discord Bot v2.0 Tests week and game validation for BatterInjuryModal and PitcherRestModal, including regular season and playoff round validation. """ + import pytest from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock @@ -36,7 +37,7 @@ def sample_player(): season=12, team_id=1, image="https://example.com/player.jpg", - pos_1="1B" + pos_1="1B", ) @@ -60,21 +61,21 @@ class TestBatterInjuryModalWeekValidation: """Test week validation in BatterInjuryModal.""" @pytest.mark.asyncio - async def test_regular_season_week_valid(self, sample_player, mock_interaction, mock_config): + async def test_regular_season_week_valid( + self, sample_player, mock_interaction, mock_config + ): """Test that regular season weeks (1-18) are accepted.""" - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) # Mock the TextInput values modal.current_week = create_mock_text_input("10") modal.current_game = create_mock_text_input("2") - with patch('config.get_config', return_value=mock_config), \ - patch('services.player_service.player_service') as mock_player_service, \ - patch('services.injury_service.injury_service') as mock_injury_service: + with patch("config.get_config", return_value=mock_config), patch( + "services.player_service.player_service" + ) as mock_player_service, patch( + "services.injury_service.injury_service" + ) as mock_injury_service: # Mock successful injury creation mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1)) @@ -84,26 +85,25 @@ class TestBatterInjuryModalWeekValidation: # Should not send error message assert not any( - call[1].get('embed') and - 'Invalid Week' in str(call[1]['embed'].title) + call[1].get("embed") and "Invalid Week" in str(call[1]["embed"].title) for call in mock_interaction.response.send_message.call_args_list ) @pytest.mark.asyncio - async def test_playoff_week_19_valid(self, sample_player, mock_interaction, mock_config): + async def test_playoff_week_19_valid( + self, sample_player, mock_interaction, mock_config + ): """Test that playoff week 19 (round 1) is accepted.""" - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("19") modal.current_game = create_mock_text_input("3") - with patch('config.get_config', return_value=mock_config), \ - patch('services.player_service.player_service') as mock_player_service, \ - patch('services.injury_service.injury_service') as mock_injury_service: + with patch("config.get_config", return_value=mock_config), patch( + "services.player_service.player_service" + ) as mock_player_service, patch( + "services.injury_service.injury_service" + ) as mock_injury_service: mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1)) mock_player_service.update_player = AsyncMock() @@ -112,26 +112,25 @@ class TestBatterInjuryModalWeekValidation: # Should not send error message assert not any( - call[1].get('embed') and - 'Invalid Week' in str(call[1]['embed'].title) + call[1].get("embed") and "Invalid Week" in str(call[1]["embed"].title) for call in mock_interaction.response.send_message.call_args_list ) @pytest.mark.asyncio - async def test_playoff_week_21_valid(self, sample_player, mock_interaction, mock_config): + async def test_playoff_week_21_valid( + self, sample_player, mock_interaction, mock_config + ): """Test that playoff week 21 (round 3) is accepted.""" - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("21") modal.current_game = create_mock_text_input("5") - with patch('config.get_config', return_value=mock_config), \ - patch('services.player_service.player_service') as mock_player_service, \ - patch('services.injury_service.injury_service') as mock_injury_service: + with patch("config.get_config", return_value=mock_config), patch( + "services.player_service.player_service" + ) as mock_player_service, patch( + "services.injury_service.injury_service" + ) as mock_injury_service: mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1)) mock_player_service.update_player = AsyncMock() @@ -140,73 +139,68 @@ class TestBatterInjuryModalWeekValidation: # Should not send error message assert not any( - call[1].get('embed') and - 'Invalid Week' in str(call[1]['embed'].title) + call[1].get("embed") and "Invalid Week" in str(call[1]["embed"].title) for call in mock_interaction.response.send_message.call_args_list ) @pytest.mark.asyncio - async def test_week_too_high_rejected(self, sample_player, mock_interaction, mock_config): + async def test_week_too_high_rejected( + self, sample_player, mock_interaction, mock_config + ): """Test that week > 21 is rejected.""" - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("22") modal.current_game = create_mock_text_input("2") - with patch('config.get_config', return_value=mock_config): + with patch("config.get_config", return_value=mock_config): await modal.on_submit(mock_interaction) # Should send error message mock_interaction.response.send_message.assert_called_once() call_kwargs = mock_interaction.response.send_message.call_args[1] - assert 'embed' in call_kwargs - assert 'Invalid Week' in call_kwargs['embed'].title - assert '21 (including playoffs)' in call_kwargs['embed'].description + assert "embed" in call_kwargs + assert "Invalid Week" in call_kwargs["embed"].title + assert "21 (including playoffs)" in call_kwargs["embed"].description @pytest.mark.asyncio - async def test_week_zero_rejected(self, sample_player, mock_interaction, mock_config): + async def test_week_zero_rejected( + self, sample_player, mock_interaction, mock_config + ): """Test that week 0 is rejected.""" - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("0") modal.current_game = create_mock_text_input("2") - with patch('config.get_config', return_value=mock_config): + with patch("config.get_config", return_value=mock_config): await modal.on_submit(mock_interaction) # Should send error message mock_interaction.response.send_message.assert_called_once() call_kwargs = mock_interaction.response.send_message.call_args[1] - assert 'embed' in call_kwargs - assert 'Invalid Week' in call_kwargs['embed'].title + assert "embed" in call_kwargs + assert "Invalid Week" in call_kwargs["embed"].title class TestBatterInjuryModalGameValidation: """Test game validation in BatterInjuryModal.""" @pytest.mark.asyncio - async def test_regular_season_game_4_valid(self, sample_player, mock_interaction, mock_config): + async def test_regular_season_game_4_valid( + self, sample_player, mock_interaction, mock_config + ): """Test that game 4 is accepted in regular season.""" - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("10") modal.current_game = create_mock_text_input("4") - with patch('config.get_config', return_value=mock_config), \ - patch('services.player_service.player_service') as mock_player_service, \ - patch('services.injury_service.injury_service') as mock_injury_service: + with patch("config.get_config", return_value=mock_config), patch( + "services.player_service.player_service" + ) as mock_player_service, patch( + "services.injury_service.injury_service" + ) as mock_injury_service: mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1)) mock_player_service.update_player = AsyncMock() @@ -215,48 +209,45 @@ class TestBatterInjuryModalGameValidation: # Should not send error about invalid game assert not any( - call[1].get('embed') and - 'Invalid Game' in str(call[1]['embed'].title) + call[1].get("embed") and "Invalid Game" in str(call[1]["embed"].title) for call in mock_interaction.response.send_message.call_args_list ) @pytest.mark.asyncio - async def test_regular_season_game_5_rejected(self, sample_player, mock_interaction, mock_config): + async def test_regular_season_game_5_rejected( + self, sample_player, mock_interaction, mock_config + ): """Test that game 5 is rejected in regular season (only 4 games).""" - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("10") modal.current_game = create_mock_text_input("5") - with patch('config.get_config', return_value=mock_config): + with patch("config.get_config", return_value=mock_config): await modal.on_submit(mock_interaction) # Should send error message mock_interaction.response.send_message.assert_called_once() call_kwargs = mock_interaction.response.send_message.call_args[1] - assert 'embed' in call_kwargs - assert 'Invalid Game' in call_kwargs['embed'].title - assert 'between 1 and 4' in call_kwargs['embed'].description + assert "embed" in call_kwargs + assert "Invalid Game" in call_kwargs["embed"].title + assert "between 1 and 4" in call_kwargs["embed"].description @pytest.mark.asyncio - async def test_playoff_round_1_game_5_valid(self, sample_player, mock_interaction, mock_config): + async def test_playoff_round_1_game_5_valid( + self, sample_player, mock_interaction, mock_config + ): """Test that game 5 is accepted in playoff round 1 (week 19).""" - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("19") modal.current_game = create_mock_text_input("5") - with patch('config.get_config', return_value=mock_config), \ - patch('services.player_service.player_service') as mock_player_service, \ - patch('services.injury_service.injury_service') as mock_injury_service: + with patch("config.get_config", return_value=mock_config), patch( + "services.player_service.player_service" + ) as mock_player_service, patch( + "services.injury_service.injury_service" + ) as mock_injury_service: mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1)) mock_player_service.update_player = AsyncMock() @@ -265,48 +256,45 @@ class TestBatterInjuryModalGameValidation: # Should not send error about invalid game assert not any( - call[1].get('embed') and - 'Invalid Game' in str(call[1]['embed'].title) + call[1].get("embed") and "Invalid Game" in str(call[1]["embed"].title) for call in mock_interaction.response.send_message.call_args_list ) @pytest.mark.asyncio - async def test_playoff_round_1_game_6_rejected(self, sample_player, mock_interaction, mock_config): + async def test_playoff_round_1_game_6_rejected( + self, sample_player, mock_interaction, mock_config + ): """Test that game 6 is rejected in playoff round 1 (only 5 games).""" - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("19") modal.current_game = create_mock_text_input("6") - with patch('config.get_config', return_value=mock_config): + with patch("config.get_config", return_value=mock_config): await modal.on_submit(mock_interaction) # Should send error message mock_interaction.response.send_message.assert_called_once() call_kwargs = mock_interaction.response.send_message.call_args[1] - assert 'embed' in call_kwargs - assert 'Invalid Game' in call_kwargs['embed'].title - assert 'between 1 and 5' in call_kwargs['embed'].description + assert "embed" in call_kwargs + assert "Invalid Game" in call_kwargs["embed"].title + assert "between 1 and 5" in call_kwargs["embed"].description @pytest.mark.asyncio - async def test_playoff_round_2_game_7_valid(self, sample_player, mock_interaction, mock_config): + async def test_playoff_round_2_game_7_valid( + self, sample_player, mock_interaction, mock_config + ): """Test that game 7 is accepted in playoff round 2 (week 20).""" - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("20") modal.current_game = create_mock_text_input("7") - with patch('config.get_config', return_value=mock_config), \ - patch('services.player_service.player_service') as mock_player_service, \ - patch('services.injury_service.injury_service') as mock_injury_service: + with patch("config.get_config", return_value=mock_config), patch( + "services.player_service.player_service" + ) as mock_player_service, patch( + "services.injury_service.injury_service" + ) as mock_injury_service: mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1)) mock_player_service.update_player = AsyncMock() @@ -315,26 +303,25 @@ class TestBatterInjuryModalGameValidation: # Should not send error about invalid game assert not any( - call[1].get('embed') and - 'Invalid Game' in str(call[1]['embed'].title) + call[1].get("embed") and "Invalid Game" in str(call[1]["embed"].title) for call in mock_interaction.response.send_message.call_args_list ) @pytest.mark.asyncio - async def test_playoff_round_3_game_7_valid(self, sample_player, mock_interaction, mock_config): + async def test_playoff_round_3_game_7_valid( + self, sample_player, mock_interaction, mock_config + ): """Test that game 7 is accepted in playoff round 3 (week 21).""" - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("21") modal.current_game = create_mock_text_input("7") - with patch('config.get_config', return_value=mock_config), \ - patch('services.player_service.player_service') as mock_player_service, \ - patch('services.injury_service.injury_service') as mock_injury_service: + with patch("config.get_config", return_value=mock_config), patch( + "services.player_service.player_service" + ) as mock_player_service, patch( + "services.injury_service.injury_service" + ) as mock_injury_service: mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1)) mock_player_service.update_player = AsyncMock() @@ -343,8 +330,7 @@ class TestBatterInjuryModalGameValidation: # Should not send error about invalid game assert not any( - call[1].get('embed') and - 'Invalid Game' in str(call[1]['embed'].title) + call[1].get("embed") and "Invalid Game" in str(call[1]["embed"].title) for call in mock_interaction.response.send_message.call_args_list ) @@ -353,21 +339,21 @@ class TestPitcherRestModalValidation: """Test week and game validation in PitcherRestModal (should match BatterInjuryModal).""" @pytest.mark.asyncio - async def test_playoff_week_19_valid(self, sample_player, mock_interaction, mock_config): + async def test_playoff_week_19_valid( + self, sample_player, mock_interaction, mock_config + ): """Test that playoff week 19 is accepted for pitchers.""" - modal = PitcherRestModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = PitcherRestModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("19") modal.current_game = create_mock_text_input("3") modal.rest_games = create_mock_text_input("2") - with patch('config.get_config', return_value=mock_config), \ - patch('services.player_service.player_service') as mock_player_service, \ - patch('services.injury_service.injury_service') as mock_injury_service: + with patch("config.get_config", return_value=mock_config), patch( + "services.player_service.player_service" + ) as mock_player_service, patch( + "services.injury_service.injury_service" + ) as mock_injury_service: mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1)) mock_player_service.update_player = AsyncMock() @@ -376,50 +362,45 @@ class TestPitcherRestModalValidation: # Should not send error about invalid week assert not any( - call[1].get('embed') and - 'Invalid Week' in str(call[1]['embed'].title) + call[1].get("embed") and "Invalid Week" in str(call[1]["embed"].title) for call in mock_interaction.response.send_message.call_args_list ) @pytest.mark.asyncio async def test_week_22_rejected(self, sample_player, mock_interaction, mock_config): """Test that week 22 is rejected for pitchers.""" - modal = PitcherRestModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = PitcherRestModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("22") modal.current_game = create_mock_text_input("2") modal.rest_games = create_mock_text_input("2") - with patch('config.get_config', return_value=mock_config): + with patch("config.get_config", return_value=mock_config): await modal.on_submit(mock_interaction) # Should send error message mock_interaction.response.send_message.assert_called_once() call_kwargs = mock_interaction.response.send_message.call_args[1] - assert 'embed' in call_kwargs - assert 'Invalid Week' in call_kwargs['embed'].title - assert '21 (including playoffs)' in call_kwargs['embed'].description + assert "embed" in call_kwargs + assert "Invalid Week" in call_kwargs["embed"].title + assert "21 (including playoffs)" in call_kwargs["embed"].description @pytest.mark.asyncio - async def test_playoff_round_2_game_7_valid(self, sample_player, mock_interaction, mock_config): + async def test_playoff_round_2_game_7_valid( + self, sample_player, mock_interaction, mock_config + ): """Test that game 7 is accepted in playoff round 2 for pitchers.""" - modal = PitcherRestModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = PitcherRestModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("20") modal.current_game = create_mock_text_input("7") modal.rest_games = create_mock_text_input("3") - with patch('config.get_config', return_value=mock_config), \ - patch('services.player_service.player_service') as mock_player_service, \ - patch('services.injury_service.injury_service') as mock_injury_service: + with patch("config.get_config", return_value=mock_config), patch( + "services.player_service.player_service" + ) as mock_player_service, patch( + "services.injury_service.injury_service" + ) as mock_injury_service: mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1)) mock_player_service.update_player = AsyncMock() @@ -428,40 +409,39 @@ class TestPitcherRestModalValidation: # Should not send error about invalid game assert not any( - call[1].get('embed') and - 'Invalid Game' in str(call[1]['embed'].title) + call[1].get("embed") and "Invalid Game" in str(call[1]["embed"].title) for call in mock_interaction.response.send_message.call_args_list ) @pytest.mark.asyncio - async def test_playoff_round_1_game_6_rejected(self, sample_player, mock_interaction, mock_config): + async def test_playoff_round_1_game_6_rejected( + self, sample_player, mock_interaction, mock_config + ): """Test that game 6 is rejected in playoff round 1 for pitchers (only 5 games).""" - modal = PitcherRestModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = PitcherRestModal(player=sample_player, injury_games=4, season=12) modal.current_week = create_mock_text_input("19") modal.current_game = create_mock_text_input("6") modal.rest_games = create_mock_text_input("2") - with patch('config.get_config', return_value=mock_config): + with patch("config.get_config", return_value=mock_config): await modal.on_submit(mock_interaction) # Should send error message mock_interaction.response.send_message.assert_called_once() call_kwargs = mock_interaction.response.send_message.call_args[1] - assert 'embed' in call_kwargs - assert 'Invalid Game' in call_kwargs['embed'].title - assert 'between 1 and 5' in call_kwargs['embed'].description + assert "embed" in call_kwargs + assert "Invalid Game" in call_kwargs["embed"].title + assert "between 1 and 5" in call_kwargs["embed"].description class TestConfigDrivenValidation: """Test that validation correctly uses config values.""" @pytest.mark.asyncio - async def test_custom_config_values_respected(self, sample_player, mock_interaction): + async def test_custom_config_values_respected( + self, sample_player, mock_interaction + ): """Test that custom config values change validation behavior.""" # Create config with different values custom_config = MagicMock() @@ -472,19 +452,17 @@ class TestConfigDrivenValidation: custom_config.playoff_round_two_games = 7 custom_config.playoff_round_three_games = 7 - modal = BatterInjuryModal( - player=sample_player, - injury_games=4, - season=12 - ) + modal = BatterInjuryModal(player=sample_player, injury_games=4, season=12) # Week 22 should be valid with this config (20 + 2 = 22) modal.current_week = create_mock_text_input("22") modal.current_game = create_mock_text_input("3") - with patch('config.get_config', return_value=custom_config), \ - patch('services.player_service.player_service') as mock_player_service, \ - patch('services.injury_service.injury_service') as mock_injury_service: + with patch("views.modals.get_config", return_value=custom_config), patch( + "views.modals.player_service" + ) as mock_player_service, patch( + "views.modals.injury_service" + ) as mock_injury_service: mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1)) mock_player_service.update_player = AsyncMock() @@ -493,7 +471,6 @@ class TestConfigDrivenValidation: # Should not send error about invalid week assert not any( - call[1].get('embed') and - 'Invalid Week' in str(call[1]['embed'].title) + call[1].get("embed") and "Invalid Week" in str(call[1]["embed"].title) for call in mock_interaction.response.send_message.call_args_list ) diff --git a/utils/autocomplete.py b/utils/autocomplete.py index 9b4371c..6980a1e 100644 --- a/utils/autocomplete.py +++ b/utils/autocomplete.py @@ -3,19 +3,20 @@ Autocomplete Utilities Shared autocomplete functions for Discord slash commands. """ + from typing import List import discord from discord import app_commands from config import get_config +from models.team import RosterType from services.player_service import player_service from services.team_service import team_service from utils.team_utils import get_user_major_league_team async def player_autocomplete( - interaction: discord.Interaction, - current: str + interaction: discord.Interaction, current: str ) -> List[app_commands.Choice[str]]: """ Autocomplete for player names with team context prioritization. @@ -37,7 +38,9 @@ async def player_autocomplete( user_team = await get_user_major_league_team(interaction.user.id) # Search for players using the search endpoint - players = await player_service.search_players(current, limit=50, season=get_config().sba_season) + players = await player_service.search_players( + current, limit=50, season=get_config().sba_season + ) # Separate players by team (user's team vs others) user_team_players = [] @@ -46,10 +49,11 @@ async def player_autocomplete( for player in players: # Check if player belongs to user's team (any roster section) is_users_player = False - if user_team and hasattr(player, 'team') and player.team: + if user_team and hasattr(player, "team") and player.team: # Check if player is from user's major league team or has same base team - if (player.team.id == user_team.id or - (hasattr(player, 'team_id') and player.team_id == user_team.id)): + if player.team.id == user_team.id or ( + hasattr(player, "team_id") and player.team_id == user_team.id + ): is_users_player = True if is_users_player: @@ -63,7 +67,7 @@ async def player_autocomplete( # Add user's team players first (prioritized) for player in user_team_players[:15]: # Limit user team players team_info = f"{player.primary_position}" - if hasattr(player, 'team') and player.team: + if hasattr(player, "team") and player.team: team_info += f" - {player.team.abbrev}" choice_name = f"{player.name} ({team_info})" @@ -73,7 +77,7 @@ async def player_autocomplete( remaining_slots = 25 - len(choices) for player in other_players[:remaining_slots]: team_info = f"{player.primary_position}" - if hasattr(player, 'team') and player.team: + if hasattr(player, "team") and player.team: team_info += f" - {player.team.abbrev}" choice_name = f"{player.name} ({team_info})" @@ -87,8 +91,7 @@ async def player_autocomplete( async def team_autocomplete( - interaction: discord.Interaction, - current: str + interaction: discord.Interaction, current: str ) -> List[app_commands.Choice[str]]: """ Autocomplete for team abbreviations. @@ -109,8 +112,10 @@ async def team_autocomplete( # Filter teams by current input and limit to 25 matching_teams = [ - team for team in teams - if current.lower() in team.abbrev.lower() or current.lower() in team.sname.lower() + team + for team in teams + if current.lower() in team.abbrev.lower() + or current.lower() in team.sname.lower() ][:25] choices = [] @@ -126,8 +131,7 @@ async def team_autocomplete( async def major_league_team_autocomplete( - interaction: discord.Interaction, - current: str + interaction: discord.Interaction, current: str ) -> List[app_commands.Choice[str]]: """ Autocomplete for Major League team abbreviations only. @@ -149,16 +153,16 @@ async def major_league_team_autocomplete( all_teams = await team_service.get_teams_by_season(get_config().sba_season) # Filter to only Major League teams using the model's helper method - from models.team import RosterType ml_teams = [ - team for team in all_teams - if team.roster_type() == RosterType.MAJOR_LEAGUE + team for team in all_teams if team.roster_type() == RosterType.MAJOR_LEAGUE ] # Filter teams by current input and limit to 25 matching_teams = [ - team for team in ml_teams - if current.lower() in team.abbrev.lower() or current.lower() in team.sname.lower() + team + for team in ml_teams + if current.lower() in team.abbrev.lower() + or current.lower() in team.sname.lower() ][:25] choices = [] @@ -170,4 +174,4 @@ async def major_league_team_autocomplete( except Exception: # Silently fail on autocomplete errors - return [] \ No newline at end of file + return [] diff --git a/utils/discord_helpers.py b/utils/discord_helpers.py index ec59fd5..f9099e0 100644 --- a/utils/discord_helpers.py +++ b/utils/discord_helpers.py @@ -4,10 +4,12 @@ Discord Helper Utilities Common Discord-related helper functions for channel lookups, message sending, and formatting. """ + from typing import Optional, List import discord from discord.ext import commands +from config import get_config from models.play import Play from models.team import Team from utils.logging import get_contextual_logger @@ -16,8 +18,7 @@ logger = get_contextual_logger(__name__) async def get_channel_by_name( - bot: commands.Bot, - channel_name: str + bot: commands.Bot, channel_name: str ) -> Optional[discord.TextChannel]: """ Get a text channel by name from the configured guild. @@ -29,8 +30,6 @@ async def get_channel_by_name( Returns: TextChannel if found, None otherwise """ - from config import get_config - config = get_config() guild_id = config.guild_id @@ -56,7 +55,7 @@ async def send_to_channel( bot: commands.Bot, channel_name: str, content: Optional[str] = None, - embed: Optional[discord.Embed] = None + embed: Optional[discord.Embed] = None, ) -> bool: """ Send a message to a channel by name. @@ -80,9 +79,9 @@ async def send_to_channel( # Build kwargs to avoid passing None for embed kwargs = {} if content is not None: - kwargs['content'] = content + kwargs["content"] = content if embed is not None: - kwargs['embed'] = embed + kwargs["embed"] = embed await channel.send(**kwargs) logger.info(f"Sent message to #{channel_name}") @@ -92,11 +91,7 @@ async def send_to_channel( return False -def format_key_plays( - plays: List[Play], - away_team: Team, - home_team: Team -) -> str: +def format_key_plays(plays: List[Play], away_team: Team, home_team: Team) -> str: """ Format top plays into embed field text. @@ -122,9 +117,7 @@ def format_key_plays( async def set_channel_visibility( - channel: discord.TextChannel, - visible: bool, - reason: Optional[str] = None + channel: discord.TextChannel, visible: bool, reason: Optional[str] = None ) -> bool: """ Set channel visibility for @everyone. @@ -148,18 +141,14 @@ async def set_channel_visibility( # Grant @everyone permission to view channel default_reason = "Channel made visible to all members" await channel.set_permissions( - everyone_role, - view_channel=True, - reason=reason or default_reason + everyone_role, view_channel=True, reason=reason or default_reason ) logger.info(f"Set #{channel.name} to VISIBLE for @everyone") else: # Remove @everyone view permission default_reason = "Channel hidden from members" await channel.set_permissions( - everyone_role, - view_channel=False, - reason=reason or default_reason + everyone_role, view_channel=False, reason=reason or default_reason ) logger.info(f"Set #{channel.name} to HIDDEN for @everyone") diff --git a/utils/draft_helpers.py b/utils/draft_helpers.py index fd5cf31..d3563d3 100644 --- a/utils/draft_helpers.py +++ b/utils/draft_helpers.py @@ -3,8 +3,10 @@ Draft utility functions for Discord Bot v2.0 Provides helper functions for draft order calculation and cap space validation. """ + import math from typing import Tuple +from utils.helpers import get_team_salary_cap, SALARY_CAP_TOLERANCE from utils.logging import get_contextual_logger from config import get_config @@ -109,9 +111,7 @@ def calculate_overall_from_round_position(round_num: int, position: int) -> int: async def validate_cap_space( - roster: dict, - new_player_wara: float, - team=None + roster: dict, new_player_wara: float, team=None ) -> Tuple[bool, float, float]: """ Validate team has cap space to draft player. @@ -138,17 +138,15 @@ async def validate_cap_space( Raises: ValueError: If roster structure is invalid """ - from utils.helpers import get_team_salary_cap, SALARY_CAP_TOLERANCE - config = get_config() cap_limit = get_team_salary_cap(team) cap_player_count = config.cap_player_count - if not roster or not roster.get('active'): + if not roster or not roster.get("active"): raise ValueError("Invalid roster structure - missing 'active' key") - active_roster = roster['active'] - current_players = active_roster.get('players', []) + active_roster = roster["active"] + current_players = active_roster.get("players", []) # Calculate how many players count toward cap after adding new player current_roster_size = len(current_players) @@ -172,7 +170,7 @@ async def validate_cap_space( players_counted = max(0, cap_player_count - max_zeroes) # Sort all players (including new) by sWAR ASCENDING (cheapest first) - all_players_wara = [p['wara'] for p in current_players] + [new_player_wara] + all_players_wara = [p["wara"] for p in current_players] + [new_player_wara] sorted_wara = sorted(all_players_wara) # Ascending order # Sum bottom N players (the cheapest ones that count toward cap) diff --git a/views/draft_views.py b/views/draft_views.py index cab021d..64953a7 100644 --- a/views/draft_views.py +++ b/views/draft_views.py @@ -3,6 +3,7 @@ Draft Views for Discord Bot v2.0 Provides embeds and UI components for draft system. """ + from typing import Optional, List import discord @@ -14,6 +15,7 @@ from models.player import Player from models.draft_list import DraftList from views.embeds import EmbedTemplate, EmbedColors from utils.draft_helpers import format_pick_display, get_round_name +from utils.helpers import get_team_salary_cap async def create_on_the_clock_embed( @@ -22,7 +24,7 @@ async def create_on_the_clock_embed( recent_picks: List[DraftPick], upcoming_picks: List[DraftPick], team_roster_swar: Optional[float] = None, - sheet_url: Optional[str] = None + sheet_url: Optional[str] = None, ) -> discord.Embed: """ Create "on the clock" embed showing current pick info. @@ -45,7 +47,7 @@ async def create_on_the_clock_embed( embed = EmbedTemplate.create_base_embed( title=f"ā° {current_pick.owner.lname} On The Clock", description=format_pick_display(current_pick.overall), - color=EmbedColors.PRIMARY + color=EmbedColors.PRIMARY, ) # Add team info @@ -53,26 +55,23 @@ async def create_on_the_clock_embed( embed.add_field( name="Team", value=f"{current_pick.owner.abbrev} {current_pick.owner.sname}", - inline=True + inline=True, ) # Add timer info if draft_data.pick_deadline: deadline_timestamp = int(draft_data.pick_deadline.timestamp()) embed.add_field( - name="Deadline", - value=f"", - inline=True + name="Deadline", value=f"", inline=True ) # Add team sWAR if provided if team_roster_swar is not None: - 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} / {cap_limit:.2f}", - inline=True + inline=True, ) # Add recent picks @@ -83,9 +82,7 @@ async def create_on_the_clock_embed( recent_str += f"**#{pick.overall}** - {pick.player.name}\n" if recent_str: embed.add_field( - name="šŸ“‹ Last 5 Picks", - value=recent_str or "None", - inline=False + name="šŸ“‹ Last 5 Picks", value=recent_str or "None", inline=False ) # Add upcoming picks @@ -94,18 +91,12 @@ async def create_on_the_clock_embed( for pick in upcoming_picks[:5]: upcoming_str += f"**#{pick.overall}** - {pick.owner.sname if pick.owner else 'Unknown'}\n" if upcoming_str: - embed.add_field( - name="šŸ”œ Next 5 Picks", - value=upcoming_str, - inline=False - ) + embed.add_field(name="šŸ”œ Next 5 Picks", value=upcoming_str, inline=False) # Draft Sheet link if sheet_url: embed.add_field( - name="šŸ“Š Draft Sheet", - value=f"[View Full Board]({sheet_url})", - inline=False + name="šŸ“Š Draft Sheet", value=f"[View Full Board]({sheet_url})", inline=False ) # Add footer @@ -119,7 +110,7 @@ async def create_draft_status_embed( draft_data: DraftData, current_pick: DraftPick, lock_status: str = "šŸ”“ No pick in progress", - sheet_url: Optional[str] = None + sheet_url: Optional[str] = None, ) -> discord.Embed: """ Create draft status embed showing current state. @@ -137,12 +128,12 @@ async def create_draft_status_embed( if draft_data.paused: embed = EmbedTemplate.warning( title="Draft Status - PAUSED", - description=f"Currently on {format_pick_display(draft_data.currentpick)}" + 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)}" + description=f"Currently on {format_pick_display(draft_data.currentpick)}", ) # On the clock @@ -150,7 +141,7 @@ async def create_draft_status_embed( embed.add_field( name="On the Clock", value=f"{current_pick.owner.abbrev} {current_pick.owner.sname}", - inline=True + inline=True, ) # Timer status (show paused state prominently) @@ -163,53 +154,40 @@ async def create_draft_status_embed( embed.add_field( name="Timer", value=f"{timer_status} ({draft_data.pick_minutes} min)", - inline=True + inline=True, ) # Deadline if draft_data.pick_deadline: deadline_timestamp = int(draft_data.pick_deadline.timestamp()) embed.add_field( - name="Deadline", - value=f"", - inline=True + name="Deadline", value=f"", inline=True ) else: - embed.add_field( - name="Deadline", - value="None", - inline=True - ) + embed.add_field(name="Deadline", value="None", 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 + inline=False, ) # Lock status - embed.add_field( - name="Lock Status", - value=lock_status, - inline=False - ) + embed.add_field(name="Lock Status", value=lock_status, inline=False) # Draft Sheet link if sheet_url: embed.add_field( - name="Draft Sheet", - value=f"[View Sheet]({sheet_url})", - inline=False + name="Draft Sheet", value=f"[View Sheet]({sheet_url})", inline=False ) return embed async def create_player_draft_card( - player: Player, - draft_pick: DraftPick + player: Player, draft_pick: DraftPick ) -> discord.Embed: """ Create player draft card embed. @@ -226,41 +204,32 @@ async def create_player_draft_card( embed = EmbedTemplate.success( title=f"{player.name} Drafted!", - description=format_pick_display(draft_pick.overall) + description=format_pick_display(draft_pick.overall), ) # Team info embed.add_field( name="Selected By", value=f"{draft_pick.owner.abbrev} {draft_pick.owner.sname}", - inline=True + inline=True, ) # Player info - if hasattr(player, 'pos_1') and player.pos_1: - embed.add_field( - name="Position", - value=player.pos_1, - inline=True - ) + if hasattr(player, "pos_1") and player.pos_1: + embed.add_field(name="Position", value=player.pos_1, inline=True) - if hasattr(player, 'wara') and player.wara is not None: - embed.add_field( - name="sWAR", - value=f"{player.wara:.2f}", - inline=True - ) + if hasattr(player, "wara") and player.wara is not None: + embed.add_field(name="sWAR", value=f"{player.wara:.2f}", inline=True) # Add player image if available - if hasattr(player, 'image') and player.image: + if hasattr(player, "image") and player.image: embed.set_thumbnail(url=player.image) return embed async def create_draft_list_embed( - team: Team, - draft_list: List[DraftList] + team: Team, draft_list: List[DraftList] ) -> discord.Embed: """ Create draft list embed showing team's auto-draft queue. @@ -274,38 +243,40 @@ async def create_draft_list_embed( """ embed = EmbedTemplate.info( title=f"{team.sname} Draft List", - description=f"Auto-draft queue for {team.abbrev}" + description=f"Auto-draft queue for {team.abbrev}", ) if not draft_list: embed.add_field( - name="Queue Empty", - value="No players in auto-draft queue", - inline=False + name="Queue Empty", value="No players in auto-draft queue", inline=False ) else: # Group players by rank list_str = "" for entry in draft_list[:25]: # Limit to 25 for embed size - player_name = entry.player.name if entry.player else f"Player {entry.player_id}" - player_swar = f" ({entry.player.wara:.2f})" if entry.player and hasattr(entry.player, 'wara') else "" + player_name = ( + entry.player.name if entry.player else f"Player {entry.player_id}" + ) + player_swar = ( + f" ({entry.player.wara:.2f})" + if entry.player and hasattr(entry.player, "wara") + else "" + ) list_str += f"**{entry.rank}.** {player_name}{player_swar}\n" embed.add_field( - name=f"Queue ({len(draft_list)} players)", - value=list_str, - inline=False + name=f"Queue ({len(draft_list)} players)", value=list_str, inline=False ) - embed.set_footer(text="Commands: /draft-list-add, /draft-list-remove, /draft-list-clear") + 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], - sheet_url: Optional[str] = None + round_num: int, picks: List[DraftPick], sheet_url: Optional[str] = None ) -> discord.Embed: """ Create draft board embed showing all picks in a round. @@ -321,14 +292,12 @@ async def create_draft_board_embed( embed = EmbedTemplate.create_base_embed( title=f"šŸ“‹ {get_round_name(round_num)}", description=f"Draft board for round {round_num}", - color=EmbedColors.PRIMARY + color=EmbedColors.PRIMARY, ) if not picks: embed.add_field( - name="No Picks", - value="No picks found for this round", - inline=False + name="No Picks", value="No picks found for this round", inline=False ) else: # Create picks display @@ -345,18 +314,12 @@ async def create_draft_board_embed( 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", - value=picks_str, - inline=False - ) + embed.add_field(name="Picks", value=picks_str, inline=False) # Draft Sheet link if sheet_url: embed.add_field( - name="Draft Sheet", - value=f"[View Full Board]({sheet_url})", - inline=False + name="Draft Sheet", value=f"[View Full Board]({sheet_url})", inline=False ) embed.set_footer(text="Use /draft-board [round] to view different rounds") @@ -365,8 +328,7 @@ async def create_draft_board_embed( async def create_pick_illegal_embed( - reason: str, - details: Optional[str] = None + reason: str, details: Optional[str] = None ) -> discord.Embed: """ Create embed for illegal pick attempt. @@ -378,17 +340,10 @@ async def create_pick_illegal_embed( Returns: Discord error embed """ - embed = EmbedTemplate.error( - title="Invalid Pick", - description=reason - ) + embed = EmbedTemplate.error(title="Invalid Pick", description=reason) if details: - embed.add_field( - name="Details", - value=details, - inline=False - ) + embed.add_field(name="Details", value=details, inline=False) return embed @@ -398,7 +353,7 @@ async def create_pick_success_embed( team: Team, pick_overall: int, projected_swar: float, - cap_limit: float | None = None + cap_limit: float | None = None, ) -> discord.Embed: """ Create embed for successful pick. @@ -413,30 +368,20 @@ async def create_pick_success_embed( Returns: Discord success embed """ - from utils.helpers import get_team_salary_cap - embed = EmbedTemplate.success( title=f"{team.sname} select **{player.name}**", - description=format_pick_display(pick_overall) + 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="Player ID", - value=f"{player.id}", - inline=True - ) + embed.add_field(name="Player ID", value=f"{player.id}", inline=True) - if hasattr(player, 'wara') and player.wara is not None: - embed.add_field( - name="sWAR", - value=f"{player.wara:.2f}", - inline=True - ) + if hasattr(player, "wara") and player.wara is not None: + embed.add_field(name="sWAR", value=f"{player.wara:.2f}", inline=True) # Use provided cap_limit or get from team if cap_limit is None: @@ -445,7 +390,7 @@ async def create_pick_success_embed( embed.add_field( name="Projected Team sWAR", value=f"{projected_swar:.2f} / {cap_limit:.2f}", - inline=False + inline=False, ) return embed @@ -454,7 +399,7 @@ async def create_pick_success_embed( async def create_admin_draft_info_embed( draft_data: DraftData, current_pick: Optional[DraftPick] = None, - sheet_url: Optional[str] = None + sheet_url: Optional[str] = None, ) -> discord.Embed: """ Create detailed admin view of draft status. @@ -472,21 +417,17 @@ async def create_admin_draft_info_embed( embed = EmbedTemplate.create_base_embed( title="āš™ļø Draft Administration - PAUSED", description="Current draft configuration and state", - color=EmbedColors.WARNING + color=EmbedColors.WARNING, ) else: embed = EmbedTemplate.create_base_embed( title="āš™ļø Draft Administration", description="Current draft configuration and state", - color=EmbedColors.INFO + color=EmbedColors.INFO, ) # Current pick - embed.add_field( - name="Current Pick", - value=str(draft_data.currentpick), - inline=True - ) + embed.add_field(name="Current Pick", value=str(draft_data.currentpick), inline=True) # Timer status (show paused prominently) if draft_data.paused: @@ -500,16 +441,12 @@ async def create_admin_draft_info_embed( timer_text = "Inactive" embed.add_field( - name="Timer Status", - value=f"{timer_emoji} {timer_text}", - inline=True + name="Timer Status", value=f"{timer_emoji} {timer_text}", inline=True ) # Timer duration embed.add_field( - name="Pick Duration", - value=f"{draft_data.pick_minutes} minutes", - inline=True + name="Pick Duration", value=f"{draft_data.pick_minutes} minutes", inline=True ) # Pause status (prominent if paused) @@ -517,31 +454,27 @@ async def create_admin_draft_info_embed( embed.add_field( name="Pause Status", value="🚫 **PAUSED** - No picks allowed\nUse `/draft-admin resume` to allow picks", - inline=False + inline=False, ) # Channels - ping_channel_value = f"<#{draft_data.ping_channel}>" if draft_data.ping_channel else "Not configured" - embed.add_field( - name="Ping Channel", - value=ping_channel_value, - inline=True + ping_channel_value = ( + f"<#{draft_data.ping_channel}>" if draft_data.ping_channel else "Not configured" ) + embed.add_field(name="Ping Channel", value=ping_channel_value, inline=True) - result_channel_value = f"<#{draft_data.result_channel}>" if draft_data.result_channel else "Not configured" - embed.add_field( - name="Result Channel", - value=result_channel_value, - inline=True + result_channel_value = ( + f"<#{draft_data.result_channel}>" + if draft_data.result_channel + else "Not configured" ) + embed.add_field(name="Result Channel", value=result_channel_value, inline=True) # Deadline if draft_data.pick_deadline: deadline_timestamp = int(draft_data.pick_deadline.timestamp()) embed.add_field( - name="Current Deadline", - value=f"", - inline=True + name="Current Deadline", value=f"", inline=True ) # Current pick owner @@ -549,15 +482,13 @@ async def create_admin_draft_info_embed( embed.add_field( name="On The Clock", value=f"{current_pick.owner.abbrev} {current_pick.owner.sname}", - inline=False + inline=False, ) # Draft Sheet link if sheet_url: embed.add_field( - name="Draft Sheet", - value=f"[View Sheet]({sheet_url})", - inline=False + name="Draft Sheet", value=f"[View Sheet]({sheet_url})", inline=False ) embed.set_footer(text="Use /draft-admin to modify draft settings") @@ -572,7 +503,7 @@ async def create_on_clock_announcement_embed( roster_swar: float, cap_limit: float, top_roster_players: List[Player], - sheet_url: Optional[str] = None + sheet_url: Optional[str] = None, ) -> discord.Embed: """ Create announcement embed for when a team is on the clock. @@ -604,7 +535,7 @@ async def create_on_clock_announcement_embed( embed = EmbedTemplate.create_base_embed( title=f"ā° {team.lname} On The Clock", description=format_pick_display(current_pick.overall), - color=team_color + color=team_color, ) # Set team thumbnail @@ -617,55 +548,41 @@ async def create_on_clock_announcement_embed( embed.add_field( name="ā±ļø Deadline", value=f" ()", - inline=True + inline=True, ) # Team sWAR embed.add_field( - name="šŸ’° Team sWAR", - value=f"{roster_swar:.2f} / {cap_limit:.2f}", - inline=True + 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 - ) + 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" + 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 - ) + 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 "?" + 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 - ) + 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 + name="šŸ“Š Draft Sheet", value=f"[View Full Board]({sheet_url})", inline=False ) # Footer with pick info diff --git a/views/help_commands.py b/views/help_commands.py index 7a9cc3a..b6a2a38 100644 --- a/views/help_commands.py +++ b/views/help_commands.py @@ -3,6 +3,8 @@ Help Command Views for Discord Bot v2.0 Interactive views and modals for the custom help system. """ + +import re from typing import Optional, List import discord @@ -23,7 +25,7 @@ class HelpCommandCreateModal(BaseModal): placeholder="e.g., trading-rules (2-32 chars, letters/numbers/dashes)", required=True, min_length=2, - max_length=32 + max_length=32, ) self.topic_title = discord.ui.TextInput( @@ -31,14 +33,14 @@ class HelpCommandCreateModal(BaseModal): placeholder="e.g., Trading Rules & Guidelines", required=True, min_length=1, - max_length=200 + max_length=200, ) self.topic_category = discord.ui.TextInput( label="Category (Optional)", placeholder="e.g., rules, guides, resources, info, faq", required=False, - max_length=50 + max_length=50, ) self.topic_content = discord.ui.TextInput( @@ -47,7 +49,7 @@ class HelpCommandCreateModal(BaseModal): style=discord.TextStyle.paragraph, required=True, min_length=1, - max_length=4000 + max_length=4000, ) self.add_item(self.topic_name) @@ -57,11 +59,9 @@ class HelpCommandCreateModal(BaseModal): async def on_submit(self, interaction: discord.Interaction): """Handle form submission.""" - import re - # Validate topic name format name = self.topic_name.value.strip().lower() - if not re.match(r'^[a-z0-9_-]+$', name): + if not re.match(r"^[a-z0-9_-]+$", name): embed = EmbedTemplate.error( title="Invalid Topic Name", description=( @@ -69,14 +69,18 @@ class HelpCommandCreateModal(BaseModal): "**Allowed:** lowercase letters, numbers, dashes, and underscores only.\n" "**Examples:** `trading-rules`, `how_to_draft`, `faq1`\n\n" "Please try again with a valid name." - ) + ), ) await interaction.response.send_message(embed=embed, ephemeral=True) return # Validate category format if provided - category = self.topic_category.value.strip().lower() if self.topic_category.value else None - if category and not re.match(r'^[a-z0-9_-]+$', category): + category = ( + self.topic_category.value.strip().lower() + if self.topic_category.value + else None + ) + if category and not re.match(r"^[a-z0-9_-]+$", category): embed = EmbedTemplate.error( title="Invalid Category", description=( @@ -84,17 +88,17 @@ class HelpCommandCreateModal(BaseModal): "**Allowed:** lowercase letters, numbers, dashes, and underscores only.\n" "**Examples:** `rules`, `guides`, `faq`\n\n" "Please try again with a valid category." - ) + ), ) await interaction.response.send_message(embed=embed, ephemeral=True) return # Store results self.result = { - 'name': name, - 'title': self.topic_title.value.strip(), - 'content': self.topic_content.value.strip(), - 'category': category + "name": name, + "title": self.topic_title.value.strip(), + "content": self.topic_content.value.strip(), + "category": category, } self.is_submitted = True @@ -102,36 +106,28 @@ class HelpCommandCreateModal(BaseModal): # Create preview embed embed = EmbedTemplate.info( title="Help Topic Preview", - description="Here's how your help topic will look:" + description="Here's how your help topic will look:", ) embed.add_field( - name="Name", - value=f"`/help {self.result['name']}`", - inline=True + name="Name", value=f"`/help {self.result['name']}`", inline=True ) embed.add_field( - name="Category", - value=self.result['category'] or "None", - inline=True + name="Category", value=self.result["category"] or "None", inline=True ) - embed.add_field( - name="Title", - value=self.result['title'], - inline=False - ) + embed.add_field(name="Title", value=self.result["title"], inline=False) # Show content preview (truncated if too long) - content_preview = self.result['content'][:500] + ('...' if len(self.result['content']) > 500 else '') - embed.add_field( - name="Content", - value=content_preview, - inline=False + content_preview = self.result["content"][:500] + ( + "..." if len(self.result["content"]) > 500 else "" ) + embed.add_field(name="Content", value=content_preview, inline=False) - embed.set_footer(text="Creating this help topic will make it available to all server members") + embed.set_footer( + text="Creating this help topic will make it available to all server members" + ) await interaction.response.send_message(embed=embed, ephemeral=True) @@ -149,15 +145,15 @@ class HelpCommandEditModal(BaseModal): default=help_command.title, required=True, min_length=1, - max_length=200 + max_length=200, ) self.topic_category = discord.ui.TextInput( label="Category (Optional)", placeholder="e.g., rules, guides, resources, info, faq", - default=help_command.category or '', + default=help_command.category or "", required=False, - max_length=50 + max_length=50, ) self.topic_content = discord.ui.TextInput( @@ -167,7 +163,7 @@ class HelpCommandEditModal(BaseModal): default=help_command.content, required=True, min_length=1, - max_length=4000 + max_length=4000, ) self.add_item(self.topic_title) @@ -178,10 +174,12 @@ class HelpCommandEditModal(BaseModal): """Handle form submission.""" # Store results self.result = { - 'name': self.original_help.name, - 'title': self.topic_title.value.strip(), - 'content': self.topic_content.value.strip(), - 'category': self.topic_category.value.strip() if self.topic_category.value else None + "name": self.original_help.name, + "title": self.topic_title.value.strip(), + "content": self.topic_content.value.strip(), + "category": ( + self.topic_category.value.strip() if self.topic_category.value else None + ), } self.is_submitted = True @@ -189,38 +187,36 @@ class HelpCommandEditModal(BaseModal): # Create preview embed showing changes embed = EmbedTemplate.info( title="Help Topic Edit Preview", - description=f"Changes to `/help {self.original_help.name}`:" + description=f"Changes to `/help {self.original_help.name}`:", ) # Show title changes if different - if self.original_help.title != self.result['title']: - embed.add_field(name="Old Title", value=self.original_help.title, inline=True) - embed.add_field(name="New Title", value=self.result['title'], inline=True) + if self.original_help.title != self.result["title"]: + embed.add_field( + name="Old Title", value=self.original_help.title, inline=True + ) + embed.add_field(name="New Title", value=self.result["title"], inline=True) embed.add_field(name="\u200b", value="\u200b", inline=True) # Spacer # Show category changes old_cat = self.original_help.category or "None" - new_cat = self.result['category'] or "None" + new_cat = self.result["category"] or "None" if old_cat != new_cat: embed.add_field(name="Old Category", value=old_cat, inline=True) embed.add_field(name="New Category", value=new_cat, inline=True) embed.add_field(name="\u200b", value="\u200b", inline=True) # Spacer # Show content preview (always show since it's the main field) - old_content = self.original_help.content[:300] + ('...' if len(self.original_help.content) > 300 else '') - new_content = self.result['content'][:300] + ('...' if len(self.result['content']) > 300 else '') - - embed.add_field( - name="Old Content", - value=old_content, - inline=False + old_content = self.original_help.content[:300] + ( + "..." if len(self.original_help.content) > 300 else "" + ) + new_content = self.result["content"][:300] + ( + "..." if len(self.result["content"]) > 300 else "" ) - embed.add_field( - name="New Content", - value=new_content, - inline=False - ) + embed.add_field(name="Old Content", value=old_content, inline=False) + + embed.add_field(name="New Content", value=new_content, inline=False) embed.set_footer(text="Changes will be visible to all server members") @@ -230,48 +226,58 @@ class HelpCommandEditModal(BaseModal): class HelpCommandDeleteConfirmView(BaseView): """Confirmation view for deleting a help topic.""" - def __init__(self, help_command: HelpCommand, *, user_id: int, timeout: float = 180.0): + def __init__( + self, help_command: HelpCommand, *, user_id: int, timeout: float = 180.0 + ): super().__init__(timeout=timeout, user_id=user_id) self.help_command = help_command self.result = None - @discord.ui.button(label="Delete Topic", emoji="šŸ—‘ļø", style=discord.ButtonStyle.danger, row=0) - async def confirm_delete(self, interaction: discord.Interaction, button: discord.ui.Button): + @discord.ui.button( + label="Delete Topic", emoji="šŸ—‘ļø", style=discord.ButtonStyle.danger, row=0 + ) + async def confirm_delete( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Confirm the topic deletion.""" self.result = True embed = EmbedTemplate.success( title="Help Topic Deleted", - description=f"The help topic `/help {self.help_command.name}` has been deleted (soft delete)." + description=f"The help topic `/help {self.help_command.name}` has been deleted (soft delete).", ) embed.add_field( name="Note", value="This topic can be restored later if needed using admin commands.", - inline=False + inline=False, ) # Disable all buttons for item in self.children: - if hasattr(item, 'disabled'): + if hasattr(item, "disabled"): item.disabled = True # type: ignore await interaction.response.edit_message(embed=embed, view=self) self.stop() - @discord.ui.button(label="Cancel", emoji="āŒ", style=discord.ButtonStyle.secondary, row=0) - async def cancel_delete(self, interaction: discord.Interaction, button: discord.ui.Button): + @discord.ui.button( + label="Cancel", emoji="āŒ", style=discord.ButtonStyle.secondary, row=0 + ) + async def cancel_delete( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Cancel the topic deletion.""" self.result = False embed = EmbedTemplate.info( title="Deletion Cancelled", - description=f"The help topic `/help {self.help_command.name}` was not deleted." + description=f"The help topic `/help {self.help_command.name}` was not deleted.", ) # Disable all buttons for item in self.children: - if hasattr(item, 'disabled'): + if hasattr(item, "disabled"): item.disabled = True # type: ignore await interaction.response.edit_message(embed=embed, view=self) @@ -287,7 +293,7 @@ class HelpCommandListView(BaseView): user_id: Optional[int] = None, category_filter: Optional[str] = None, *, - timeout: float = 300.0 + timeout: float = 300.0, ): super().__init__(timeout=timeout, user_id=user_id) self.help_commands = help_commands @@ -299,7 +305,11 @@ class HelpCommandListView(BaseView): def _update_buttons(self): """Update button states based on current page.""" - total_pages = max(1, (len(self.help_commands) + self.topics_per_page - 1) // self.topics_per_page) + total_pages = max( + 1, + (len(self.help_commands) + self.topics_per_page - 1) + // self.topics_per_page, + ) self.previous_page.disabled = self.current_page == 0 self.next_page.disabled = self.current_page >= total_pages - 1 @@ -324,16 +334,14 @@ class HelpCommandListView(BaseView): description = f"Found {len(self.help_commands)} help topic{'s' if len(self.help_commands) != 1 else ''}" embed = EmbedTemplate.create_base_embed( - title=title, - description=description, - color=EmbedColors.INFO + title=title, description=description, color=EmbedColors.INFO ) if not current_topics: embed.add_field( name="No Topics", value="No help topics found. Admins can create topics using `/help-create`.", - inline=False + inline=False, ) else: # Group by category for better organization @@ -347,13 +355,15 @@ class HelpCommandListView(BaseView): for category, topics in sorted(by_category.items()): topic_list = [] for topic in topics: - views_text = f" • {topic.view_count} views" if topic.view_count > 0 else "" - topic_list.append(f"• `/help {topic.name}` - {topic.title}{views_text}") + views_text = ( + f" • {topic.view_count} views" if topic.view_count > 0 else "" + ) + topic_list.append( + f"• `/help {topic.name}` - {topic.title}{views_text}" + ) embed.add_field( - name=f"šŸ“‚ {category}", - value='\n'.join(topic_list), - inline=False + name=f"šŸ“‚ {category}", value="\n".join(topic_list), inline=False ) embed.set_footer(text="Use /help to view a specific topic") @@ -361,7 +371,9 @@ class HelpCommandListView(BaseView): return embed @discord.ui.button(emoji="ā—€ļø", style=discord.ButtonStyle.secondary, row=0) - async def previous_page(self, interaction: discord.Interaction, button: discord.ui.Button): + async def previous_page( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Go to previous page.""" self.current_page = max(0, self.current_page - 1) self._update_buttons() @@ -369,15 +381,25 @@ class HelpCommandListView(BaseView): embed = self._create_embed() await interaction.response.edit_message(embed=embed, view=self) - @discord.ui.button(label="1/1", style=discord.ButtonStyle.secondary, disabled=True, row=0) - async def page_info(self, interaction: discord.Interaction, button: discord.ui.Button): + @discord.ui.button( + label="1/1", style=discord.ButtonStyle.secondary, disabled=True, row=0 + ) + async def page_info( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Page info (disabled button).""" pass @discord.ui.button(emoji="ā–¶ļø", style=discord.ButtonStyle.secondary, row=0) - async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button): + async def next_page( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Go to next page.""" - total_pages = max(1, (len(self.help_commands) + self.topics_per_page - 1) // self.topics_per_page) + total_pages = max( + 1, + (len(self.help_commands) + self.topics_per_page - 1) + // self.topics_per_page, + ) self.current_page = min(total_pages - 1, self.current_page + 1) self._update_buttons() @@ -387,7 +409,7 @@ class HelpCommandListView(BaseView): async def on_timeout(self): """Handle view timeout.""" for item in self.children: - if hasattr(item, 'disabled'): + if hasattr(item, "disabled"): item.disabled = True # type: ignore def get_embed(self) -> discord.Embed: @@ -408,7 +430,7 @@ def create_help_topic_embed(help_command: HelpCommand) -> discord.Embed: embed = EmbedTemplate.create_base_embed( title=help_command.title, description=help_command.content, - color=EmbedColors.INFO + color=EmbedColors.INFO, ) # Add metadata footer diff --git a/views/modals.py b/views/modals.py index 857c5a8..2ba2506 100644 --- a/views/modals.py +++ b/views/modals.py @@ -3,58 +3,73 @@ Modal Components for Discord Bot v2.0 Interactive forms and input dialogs for collecting user data. """ + from typing import Optional, Callable, Awaitable, Dict, Any, List +import math import re import discord +from config import get_config from .embeds import EmbedTemplate +from services.injury_service import injury_service +from services.player_service import player_service +from utils.injury_log import post_injury_and_update_log from utils.logging import get_contextual_logger class BaseModal(discord.ui.Modal): """Base modal class with consistent error handling and validation.""" - + def __init__( self, *, title: str, timeout: Optional[float] = 300.0, - custom_id: Optional[str] = None + custom_id: Optional[str] = None, ): kwargs = {"title": title, "timeout": timeout} if custom_id is not None: kwargs["custom_id"] = custom_id super().__init__(**kwargs) - self.logger = get_contextual_logger(f'{__name__}.{self.__class__.__name__}') + self.logger = get_contextual_logger(f"{__name__}.{self.__class__.__name__}") self.result: Optional[Dict[str, Any]] = None self.is_submitted = False - - async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: + + async def on_error( + self, interaction: discord.Interaction, error: Exception + ) -> None: """Handle modal errors.""" - self.logger.error("Modal error occurred", - error=error, - modal_title=self.title, - user_id=interaction.user.id) - + self.logger.error( + "Modal error occurred", + error=error, + modal_title=self.title, + user_id=interaction.user.id, + ) + try: embed = EmbedTemplate.error( title="Form Error", - description="An error occurred while processing your form. Please try again." + description="An error occurred while processing your form. Please try again.", ) - + if not interaction.response.is_done(): await interaction.response.send_message(embed=embed, ephemeral=True) else: await interaction.followup.send(embed=embed, ephemeral=True) except Exception as e: self.logger.error("Failed to send error message", error=e) - - def validate_input(self, field_name: str, value: str, validators: Optional[List[Callable[[str], bool]]] = None) -> tuple[bool, str]: + + def validate_input( + self, + field_name: str, + value: str, + validators: Optional[List[Callable[[str], bool]]] = None, + ) -> tuple[bool, str]: """Validate input field with optional custom validators.""" if not value.strip(): return False, f"{field_name} cannot be empty." - + if validators: for validator in validators: try: @@ -62,49 +77,49 @@ class BaseModal(discord.ui.Modal): return False, f"Invalid {field_name} format." except Exception: return False, f"Validation error for {field_name}." - + return True, "" class PlayerSearchModal(BaseModal): """Modal for collecting detailed player search criteria.""" - + def __init__(self, *, timeout: Optional[float] = 300.0): super().__init__(title="Player Search", timeout=timeout) - + self.player_name = discord.ui.TextInput( label="Player Name", placeholder="Enter player name (required)", required=True, - max_length=100 + max_length=100, ) - + self.position = discord.ui.TextInput( label="Position", placeholder="e.g., SS, OF, P (optional)", required=False, - max_length=10 + max_length=10, ) - + self.team = discord.ui.TextInput( label="Team", placeholder="Team abbreviation (optional)", required=False, - max_length=5 + max_length=5, ) - + self.season = discord.ui.TextInput( label="Season", placeholder="Season number (optional)", required=False, - max_length=4 + max_length=4, ) - + self.add_item(self.player_name) self.add_item(self.position) self.add_item(self.team) self.add_item(self.season) - + async def on_submit(self, interaction: discord.Interaction): """Handle form submission.""" # Validate season if provided @@ -117,60 +132,62 @@ class PlayerSearchModal(BaseModal): except ValueError: embed = EmbedTemplate.error( title="Invalid Season", - description="Season must be a valid number between 1 and 50." + description="Season must be a valid number between 1 and 50.", ) await interaction.response.send_message(embed=embed, ephemeral=True) return - + # Store results self.result = { - 'name': self.player_name.value.strip(), - 'position': self.position.value.strip() if self.position.value else None, - 'team': self.team.value.strip().upper() if self.team.value else None, - 'season': season_value + "name": self.player_name.value.strip(), + "position": self.position.value.strip() if self.position.value else None, + "team": self.team.value.strip().upper() if self.team.value else None, + "season": season_value, } - + self.is_submitted = True - + # Acknowledge submission embed = EmbedTemplate.info( title="Search Submitted", - description=f"Searching for player: **{self.result['name']}**" + description=f"Searching for player: **{self.result['name']}**", ) - - if self.result['position']: - embed.add_field(name="Position", value=self.result['position'], inline=True) - if self.result['team']: - embed.add_field(name="Team", value=self.result['team'], inline=True) - if self.result['season']: - embed.add_field(name="Season", value=str(self.result['season']), inline=True) - + + if self.result["position"]: + embed.add_field(name="Position", value=self.result["position"], inline=True) + if self.result["team"]: + embed.add_field(name="Team", value=self.result["team"], inline=True) + if self.result["season"]: + embed.add_field( + name="Season", value=str(self.result["season"]), inline=True + ) + await interaction.response.send_message(embed=embed, ephemeral=True) class TeamSearchModal(BaseModal): """Modal for collecting team search criteria.""" - + def __init__(self, *, timeout: Optional[float] = 300.0): super().__init__(title="Team Search", timeout=timeout) - + self.team_input = discord.ui.TextInput( label="Team Name or Abbreviation", placeholder="Enter team name or abbreviation", required=True, - max_length=50 + max_length=50, ) - + self.season = discord.ui.TextInput( label="Season", placeholder="Season number (optional)", required=False, - max_length=4 + max_length=4, ) - + self.add_item(self.team_input) self.add_item(self.season) - + async def on_submit(self, interaction: discord.Interaction): """Handle form submission.""" # Validate season if provided @@ -183,267 +200,267 @@ class TeamSearchModal(BaseModal): except ValueError: embed = EmbedTemplate.error( title="Invalid Season", - description="Season must be a valid number between 1 and 50." + description="Season must be a valid number between 1 and 50.", ) await interaction.response.send_message(embed=embed, ephemeral=True) return - + # Store results - self.result = { - 'team': self.team_input.value.strip(), - 'season': season_value - } - + self.result = {"team": self.team_input.value.strip(), "season": season_value} + self.is_submitted = True - + # Acknowledge submission embed = EmbedTemplate.info( title="Search Submitted", - description=f"Searching for team: **{self.result['team']}**" + description=f"Searching for team: **{self.result['team']}**", ) - - if self.result['season']: - embed.add_field(name="Season", value=str(self.result['season']), inline=True) - + + if self.result["season"]: + embed.add_field( + name="Season", value=str(self.result["season"]), inline=True + ) + await interaction.response.send_message(embed=embed, ephemeral=True) class FeedbackModal(BaseModal): """Modal for collecting user feedback.""" - + def __init__( - self, - *, + self, + *, timeout: Optional[float] = 600.0, - submit_callback: Optional[Callable[[Dict[str, Any]], Awaitable[bool]]] = None + submit_callback: Optional[Callable[[Dict[str, Any]], Awaitable[bool]]] = None, ): super().__init__(title="Submit Feedback", timeout=timeout) self.submit_callback = submit_callback - + self.feedback_type = discord.ui.TextInput( label="Feedback Type", placeholder="e.g., Bug Report, Feature Request, General", required=True, - max_length=50 + max_length=50, ) - + self.subject = discord.ui.TextInput( label="Subject", placeholder="Brief description of your feedback", required=True, - max_length=100 + max_length=100, ) - + self.description = discord.ui.TextInput( label="Description", placeholder="Detailed description of your feedback", style=discord.TextStyle.paragraph, required=True, - max_length=2000 + max_length=2000, ) - + self.contact = discord.ui.TextInput( label="Contact Info (Optional)", placeholder="How to reach you for follow-up", required=False, - max_length=100 + max_length=100, ) - + self.add_item(self.feedback_type) self.add_item(self.subject) self.add_item(self.description) self.add_item(self.contact) - + async def on_submit(self, interaction: discord.Interaction): """Handle feedback submission.""" # Store results self.result = { - 'type': self.feedback_type.value.strip(), - 'subject': self.subject.value.strip(), - 'description': self.description.value.strip(), - 'contact': self.contact.value.strip() if self.contact.value else None, - 'user_id': interaction.user.id, - 'username': str(interaction.user), - 'submitted_at': discord.utils.utcnow() + "type": self.feedback_type.value.strip(), + "subject": self.subject.value.strip(), + "description": self.description.value.strip(), + "contact": self.contact.value.strip() if self.contact.value else None, + "user_id": interaction.user.id, + "username": str(interaction.user), + "submitted_at": discord.utils.utcnow(), } - + self.is_submitted = True - + # Process feedback if self.submit_callback: try: success = await self.submit_callback(self.result) - + if success: embed = EmbedTemplate.success( title="Feedback Submitted", - description="Thank you for your feedback! We'll review it shortly." + description="Thank you for your feedback! We'll review it shortly.", ) else: embed = EmbedTemplate.error( title="Submission Failed", - description="Failed to submit feedback. Please try again later." + description="Failed to submit feedback. Please try again later.", ) except Exception as e: self.logger.error("Feedback submission error", error=e) embed = EmbedTemplate.error( title="Submission Error", - description="An error occurred while submitting feedback." + description="An error occurred while submitting feedback.", ) else: embed = EmbedTemplate.success( title="Feedback Received", - description="Your feedback has been recorded." + description="Your feedback has been recorded.", ) - + await interaction.response.send_message(embed=embed, ephemeral=True) class ConfigurationModal(BaseModal): """Modal for configuration settings with validation.""" - + def __init__( self, current_config: Dict[str, Any], *, timeout: Optional[float] = 300.0, - save_callback: Optional[Callable[[Dict[str, Any]], Awaitable[bool]]] = None + save_callback: Optional[Callable[[Dict[str, Any]], Awaitable[bool]]] = None, ): super().__init__(title="Configuration Settings", timeout=timeout) self.current_config = current_config self.save_callback = save_callback - + # Add configuration fields (customize based on needs) self.setting1 = discord.ui.TextInput( label="Setting 1", placeholder="Enter value for setting 1", - default=str(current_config.get('setting1', '')), + default=str(current_config.get("setting1", "")), required=False, - max_length=100 + max_length=100, ) - + self.setting2 = discord.ui.TextInput( label="Setting 2", placeholder="Enter value for setting 2", - default=str(current_config.get('setting2', '')), + default=str(current_config.get("setting2", "")), required=False, - max_length=100 + max_length=100, ) - + self.add_item(self.setting1) self.add_item(self.setting2) - + async def on_submit(self, interaction: discord.Interaction): """Handle configuration submission.""" # Validate and store new configuration new_config = self.current_config.copy() - + if self.setting1.value: - new_config['setting1'] = self.setting1.value.strip() - + new_config["setting1"] = self.setting1.value.strip() + if self.setting2.value: - new_config['setting2'] = self.setting2.value.strip() - + new_config["setting2"] = self.setting2.value.strip() + self.result = new_config self.is_submitted = True - + # Save configuration if self.save_callback: try: success = await self.save_callback(new_config) - + if success: embed = EmbedTemplate.success( title="Configuration Saved", - description="Your configuration has been updated successfully." + description="Your configuration has been updated successfully.", ) else: embed = EmbedTemplate.error( title="Save Failed", - description="Failed to save configuration. Please try again." + description="Failed to save configuration. Please try again.", ) except Exception as e: self.logger.error("Configuration save error", error=e) embed = EmbedTemplate.error( title="Save Error", - description="An error occurred while saving configuration." + description="An error occurred while saving configuration.", ) else: embed = EmbedTemplate.success( title="Configuration Updated", - description="Configuration has been updated." + description="Configuration has been updated.", ) - + await interaction.response.send_message(embed=embed, ephemeral=True) class CustomInputModal(BaseModal): """Flexible modal for custom input collection.""" - + def __init__( self, title: str, fields: List[Dict[str, Any]], *, timeout: Optional[float] = 300.0, - submit_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None + submit_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, ): super().__init__(title=title, timeout=timeout) self.submit_callback = submit_callback self.fields_config = fields - + # Add text inputs based on field configuration for field in fields[:5]: # Discord limit of 5 text inputs text_input = discord.ui.TextInput( - label=field.get('label', 'Field'), - placeholder=field.get('placeholder', ''), - default=field.get('default', ''), - required=field.get('required', False), - max_length=field.get('max_length', 4000), - style=getattr(discord.TextStyle, field.get('style', 'short')) + label=field.get("label", "Field"), + placeholder=field.get("placeholder", ""), + default=field.get("default", ""), + required=field.get("required", False), + max_length=field.get("max_length", 4000), + style=getattr(discord.TextStyle, field.get("style", "short")), ) - + self.add_item(text_input) - + async def on_submit(self, interaction: discord.Interaction): """Handle custom form submission.""" # Collect all input values results = {} - + for i, item in enumerate(self.children): if isinstance(item, discord.ui.TextInput): - field_config = self.fields_config[i] if i < len(self.fields_config) else {} - field_key = field_config.get('key', f'field_{i}') - + field_config = ( + self.fields_config[i] if i < len(self.fields_config) else {} + ) + field_key = field_config.get("key", f"field_{i}") + # Apply validation if specified - validators = field_config.get('validators', []) + validators = field_config.get("validators", []) if validators: is_valid, error_msg = self.validate_input( - field_config.get('label', 'Field'), - item.value, - validators + field_config.get("label", "Field"), item.value, validators ) - + if not is_valid: embed = EmbedTemplate.error( - title="Validation Error", - description=error_msg + title="Validation Error", description=error_msg + ) + await interaction.response.send_message( + embed=embed, ephemeral=True ) - await interaction.response.send_message(embed=embed, ephemeral=True) return - + results[field_key] = item.value.strip() if item.value else None - + self.result = results self.is_submitted = True - + # Execute callback if provided if self.submit_callback: await self.submit_callback(results) else: embed = EmbedTemplate.success( title="Form Submitted", - description="Your form has been submitted successfully." + description="Your form has been submitted successfully.", ) await interaction.response.send_message(embed=embed, ephemeral=True) @@ -451,7 +468,7 @@ class CustomInputModal(BaseModal): # Validation helper functions def validate_email(email: str) -> bool: """Validate email format.""" - pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" return bool(re.match(pattern, email)) @@ -492,11 +509,11 @@ class BatterInjuryModal(BaseModal): def __init__( self, - player: 'Player', + player: "Player", injury_games: int, season: int, *, - timeout: Optional[float] = 300.0 + timeout: Optional[float] = 300.0, ): """ Initialize batter injury modal. @@ -519,7 +536,7 @@ class BatterInjuryModal(BaseModal): placeholder="Enter current week number (e.g., 5)", required=True, max_length=2, - style=discord.TextStyle.short + style=discord.TextStyle.short, ) # Current game input @@ -528,7 +545,7 @@ class BatterInjuryModal(BaseModal): placeholder="Enter current game number (1-4)", required=True, max_length=1, - style=discord.TextStyle.short + style=discord.TextStyle.short, ) self.add_item(self.current_week) @@ -536,11 +553,6 @@ class BatterInjuryModal(BaseModal): async def on_submit(self, interaction: discord.Interaction): """Handle batter injury input and log injury.""" - from services.player_service import player_service - from services.injury_service import injury_service - from config import get_config - import math - config = get_config() max_week = config.weeks_per_season + config.playoff_weeks_per_season @@ -552,7 +564,7 @@ class BatterInjuryModal(BaseModal): except ValueError: embed = EmbedTemplate.error( title="Invalid Week", - description=f"Current week must be a number between 1 and {max_week} (including playoffs)." + description=f"Current week must be a number between 1 and {max_week} (including playoffs).", ) await interaction.response.send_message(embed=embed, ephemeral=True) return @@ -577,7 +589,7 @@ class BatterInjuryModal(BaseModal): except ValueError: embed = EmbedTemplate.error( title="Invalid Game", - description=f"Current game must be a number between 1 and {max_game}." + description=f"Current game must be a number between 1 and {max_game}.", ) await interaction.response.send_message(embed=embed, ephemeral=True) return @@ -597,7 +609,7 @@ class BatterInjuryModal(BaseModal): start_week = week if game != config.games_per_week else week + 1 start_game = game + 1 if game != config.games_per_week else 1 - return_date = f'w{return_week:02d}g{return_game}' + return_date = f"w{return_week:02d}g{return_game}" # Create injury record try: @@ -608,70 +620,69 @@ class BatterInjuryModal(BaseModal): start_week=start_week, start_game=start_game, end_week=return_week, - end_game=return_game + end_game=return_game, ) if not injury: raise ValueError("Failed to create injury record") # Update player's il_return field - await player_service.update_player(self.player.id, {'il_return': return_date}) + await player_service.update_player( + self.player.id, {"il_return": return_date} + ) # Success response embed = EmbedTemplate.success( title="Injury Logged", - description=f"{self.player.name}'s injury has been logged." + description=f"{self.player.name}'s injury has been logged.", ) embed.add_field( name="Duration", value=f"{self.injury_games} game{'s' if self.injury_games > 1 else ''}", - inline=True + inline=True, ) - embed.add_field( - name="Return Date", - value=return_date, - inline=True - ) + embed.add_field(name="Return Date", value=return_date, inline=True) if self.player.team: embed.add_field( name="Team", value=f"{self.player.team.lname} ({self.player.team.abbrev})", - inline=False + inline=False, ) self.is_submitted = True self.result = { - 'injury_id': injury.id, - 'total_games': self.injury_games, - 'return_date': return_date + "injury_id": injury.id, + "total_games": self.injury_games, + "return_date": return_date, } await interaction.response.send_message(embed=embed) # Post injury news and update injury log channel try: - from utils.injury_log import post_injury_and_update_log await post_injury_and_update_log( bot=interaction.client, player=self.player, injury_games=self.injury_games, return_date=return_date, - season=self.season + season=self.season, ) except Exception as log_error: self.logger.warning( f"Failed to post injury to channels (injury was still logged): {log_error}", - player_id=self.player.id + player_id=self.player.id, ) except Exception as e: - self.logger.error("Failed to create batter injury", error=e, player_id=self.player.id) + self.logger.error( + "Failed to create batter injury", error=e, player_id=self.player.id + ) embed = EmbedTemplate.error( title="Error", - description="Failed to log the injury. Please try again or contact an administrator." + description="Failed to log the injury. Please try again or contact an administrator.", ) await interaction.response.send_message(embed=embed, ephemeral=True) @@ -681,11 +692,11 @@ class PitcherRestModal(BaseModal): def __init__( self, - player: 'Player', + player: "Player", injury_games: int, season: int, *, - timeout: Optional[float] = 300.0 + timeout: Optional[float] = 300.0, ): """ Initialize pitcher rest modal. @@ -708,7 +719,7 @@ class PitcherRestModal(BaseModal): placeholder="Enter current week number (e.g., 5)", required=True, max_length=2, - style=discord.TextStyle.short + style=discord.TextStyle.short, ) # Current game input @@ -717,7 +728,7 @@ class PitcherRestModal(BaseModal): placeholder="Enter current game number (1-4)", required=True, max_length=1, - style=discord.TextStyle.short + style=discord.TextStyle.short, ) # Rest games input @@ -726,7 +737,7 @@ class PitcherRestModal(BaseModal): placeholder="Enter number of rest games (0 or more)", required=True, max_length=2, - style=discord.TextStyle.short + style=discord.TextStyle.short, ) self.add_item(self.current_week) @@ -735,11 +746,6 @@ class PitcherRestModal(BaseModal): async def on_submit(self, interaction: discord.Interaction): """Handle pitcher rest input and log injury.""" - from services.player_service import player_service - from services.injury_service import injury_service - from config import get_config - import math - config = get_config() max_week = config.weeks_per_season + config.playoff_weeks_per_season @@ -751,7 +757,7 @@ class PitcherRestModal(BaseModal): except ValueError: embed = EmbedTemplate.error( title="Invalid Week", - description=f"Current week must be a number between 1 and {max_week} (including playoffs)." + description=f"Current week must be a number between 1 and {max_week} (including playoffs).", ) await interaction.response.send_message(embed=embed, ephemeral=True) return @@ -776,7 +782,7 @@ class PitcherRestModal(BaseModal): except ValueError: embed = EmbedTemplate.error( title="Invalid Game", - description=f"Current game must be a number between 1 and {max_game}." + description=f"Current game must be a number between 1 and {max_game}.", ) await interaction.response.send_message(embed=embed, ephemeral=True) return @@ -789,7 +795,7 @@ class PitcherRestModal(BaseModal): except ValueError: embed = EmbedTemplate.error( title="Invalid Rest Games", - description="Rest games must be a non-negative number." + description="Rest games must be a non-negative number.", ) await interaction.response.send_message(embed=embed, ephemeral=True) return @@ -812,7 +818,7 @@ class PitcherRestModal(BaseModal): start_week = week if game != 4 else week + 1 start_game = game + 1 if game != 4 else 1 - return_date = f'w{return_week:02d}g{return_game}' + return_date = f"w{return_week:02d}g{return_game}" # Create injury record try: @@ -823,81 +829,80 @@ class PitcherRestModal(BaseModal): start_week=start_week, start_game=start_game, end_week=return_week, - end_game=return_game + end_game=return_game, ) if not injury: raise ValueError("Failed to create injury record") # Update player's il_return field - await player_service.update_player(self.player.id, {'il_return': return_date}) + await player_service.update_player( + self.player.id, {"il_return": return_date} + ) # Success response embed = EmbedTemplate.success( title="Injury Logged", - description=f"{self.player.name}'s injury has been logged." + description=f"{self.player.name}'s injury has been logged.", ) embed.add_field( name="Base Injury", value=f"{self.injury_games} game{'s' if self.injury_games > 1 else ''}", - inline=True + inline=True, ) embed.add_field( name="Rest Requirement", value=f"{rest} game{'s' if rest > 1 else ''}", - inline=True + inline=True, ) embed.add_field( name="Total Duration", value=f"{total_injury_games} game{'s' if total_injury_games > 1 else ''}", - inline=True + inline=True, ) - embed.add_field( - name="Return Date", - value=return_date, - inline=True - ) + embed.add_field(name="Return Date", value=return_date, inline=True) if self.player.team: embed.add_field( name="Team", value=f"{self.player.team.lname} ({self.player.team.abbrev})", - inline=False + inline=False, ) self.is_submitted = True self.result = { - 'injury_id': injury.id, - 'total_games': total_injury_games, - 'return_date': return_date + "injury_id": injury.id, + "total_games": total_injury_games, + "return_date": return_date, } await interaction.response.send_message(embed=embed) # Post injury news and update injury log channel try: - from utils.injury_log import post_injury_and_update_log await post_injury_and_update_log( bot=interaction.client, player=self.player, injury_games=total_injury_games, return_date=return_date, - season=self.season + season=self.season, ) except Exception as log_error: self.logger.warning( f"Failed to post injury to channels (injury was still logged): {log_error}", - player_id=self.player.id + player_id=self.player.id, ) except Exception as e: - self.logger.error("Failed to create pitcher injury", error=e, player_id=self.player.id) + self.logger.error( + "Failed to create pitcher injury", error=e, player_id=self.player.id + ) embed = EmbedTemplate.error( title="Error", - description="Failed to log the injury. Please try again or contact an administrator." + description="Failed to log the injury. Please try again or contact an administrator.", ) - await interaction.response.send_message(embed=embed, ephemeral=True) \ No newline at end of file + await interaction.response.send_message(embed=embed, ephemeral=True) diff --git a/views/trade_embed.py b/views/trade_embed.py index 507299f..3b4406c 100644 --- a/views/trade_embed.py +++ b/views/trade_embed.py @@ -3,13 +3,21 @@ Interactive Trade Embed Views Handles the Discord embed and button interfaces for the multi-team trade builder. """ + import discord from typing import Optional, List from datetime import datetime, timezone -from services.trade_builder import TradeBuilder +from services.trade_builder import TradeBuilder, clear_trade_builder_by_team +from services.team_service import team_service +from services.league_service import league_service +from services.transaction_service import transaction_service from models.team import Team, RosterType +from models.trade import TradeStatus +from models.transaction import Transaction from views.embeds import EmbedColors, EmbedTemplate +from utils.transaction_logging import post_trade_to_log +from config import get_config class TradeEmbedView(discord.ui.View): @@ -32,7 +40,7 @@ class TradeEmbedView(discord.ui.View): if interaction.user.id != self.user_id: await interaction.response.send_message( "āŒ You don't have permission to use this trade builder.", - ephemeral=True + ephemeral=True, ) return False return True @@ -45,12 +53,13 @@ class TradeEmbedView(discord.ui.View): item.disabled = True @discord.ui.button(label="Remove Move", style=discord.ButtonStyle.red, emoji="āž–") - async def remove_move_button(self, interaction: discord.Interaction, button: discord.ui.Button): + async def remove_move_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Handle remove move button click.""" if self.builder.is_empty: await interaction.response.send_message( - "āŒ No moves to remove. Add some moves first!", - ephemeral=True + "āŒ No moves to remove. Add some moves first!", ephemeral=True ) return @@ -60,8 +69,12 @@ class TradeEmbedView(discord.ui.View): await interaction.response.edit_message(embed=embed, view=select_view) - @discord.ui.button(label="Validate Trade", style=discord.ButtonStyle.secondary, emoji="šŸ”") - async def validate_button(self, interaction: discord.Interaction, button: discord.ui.Button): + @discord.ui.button( + label="Validate Trade", style=discord.ButtonStyle.secondary, emoji="šŸ”" + ) + async def validate_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Handle validate trade button click.""" await interaction.response.defer(ephemeral=True) @@ -81,7 +94,7 @@ class TradeEmbedView(discord.ui.View): embed = EmbedTemplate.create_base_embed( title=f"{status_emoji} Trade Validation Report", description=status_text, - color=color + color=color, ) # Add team-by-team validation @@ -100,35 +113,32 @@ class TradeEmbedView(discord.ui.View): embed.add_field( name=f"šŸŸļø {participant.team.abbrev} - {participant.team.sname}", value="\n".join(team_status), - inline=False + inline=False, ) # Add overall errors and suggestions if validation.all_errors: error_text = "\n".join([f"• {error}" for error in validation.all_errors]) - embed.add_field( - name="āŒ Errors", - value=error_text, - inline=False - ) + embed.add_field(name="āŒ Errors", value=error_text, inline=False) if validation.all_suggestions: - suggestion_text = "\n".join([f"šŸ’” {suggestion}" for suggestion in validation.all_suggestions]) - embed.add_field( - name="šŸ’” Suggestions", - value=suggestion_text, - inline=False + suggestion_text = "\n".join( + [f"šŸ’” {suggestion}" for suggestion in validation.all_suggestions] ) + embed.add_field(name="šŸ’” Suggestions", value=suggestion_text, inline=False) await interaction.followup.send(embed=embed, ephemeral=True) - @discord.ui.button(label="Submit Trade", style=discord.ButtonStyle.primary, emoji="šŸ“¤") - async def submit_button(self, interaction: discord.Interaction, button: discord.ui.Button): + @discord.ui.button( + label="Submit Trade", style=discord.ButtonStyle.primary, emoji="šŸ“¤" + ) + async def submit_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Handle submit trade button click.""" if self.builder.is_empty: await interaction.response.send_message( - "āŒ Cannot submit empty trade. Add some moves first!", - ephemeral=True + "āŒ Cannot submit empty trade. Add some moves first!", ephemeral=True ) return @@ -140,7 +150,9 @@ class TradeEmbedView(discord.ui.View): if validation.all_suggestions: error_msg += "\n\n**Suggestions:**\n" - error_msg += "\n".join([f"šŸ’” {suggestion}" for suggestion in validation.all_suggestions]) + error_msg += "\n".join( + [f"šŸ’” {suggestion}" for suggestion in validation.all_suggestions] + ) await interaction.response.send_message(error_msg, ephemeral=True) return @@ -149,8 +161,12 @@ class TradeEmbedView(discord.ui.View): modal = SubmitTradeConfirmationModal(self.builder) await interaction.response.send_modal(modal) - @discord.ui.button(label="Cancel Trade", style=discord.ButtonStyle.secondary, emoji="āŒ") - async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button): + @discord.ui.button( + label="Cancel Trade", style=discord.ButtonStyle.secondary, emoji="āŒ" + ) + async def cancel_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Handle cancel trade button click.""" self.builder.clear_trade() embed = await create_trade_embed(self.builder) @@ -161,9 +177,7 @@ class TradeEmbedView(discord.ui.View): item.disabled = True await interaction.response.edit_message( - content="āŒ **Trade cancelled and cleared.**", - embed=embed, - view=self + content="āŒ **Trade cancelled and cleared.**", embed=embed, view=self ) self.stop() @@ -181,7 +195,9 @@ class RemoveTradeMovesView(discord.ui.View): self.add_item(RemoveTradeMovesSelect(builder)) # Add back button - back_button = discord.ui.Button(label="Back", style=discord.ButtonStyle.secondary, emoji="ā¬…ļø") + back_button = discord.ui.Button( + label="Back", style=discord.ButtonStyle.secondary, emoji="ā¬…ļø" + ) back_button.callback = self.back_callback self.add_item(back_button) @@ -207,30 +223,36 @@ class RemoveTradeMovesSelect(discord.ui.Select): move_count = 0 # Add cross-team moves - for move in builder.trade.cross_team_moves[:20]: # Limit to avoid Discord's 25 option limit - options.append(discord.SelectOption( - label=f"{move.player.name}", - description=move.description[:100], # Discord description limit - value=str(move.player.id), - emoji="šŸ”„" - )) + for move in builder.trade.cross_team_moves[ + :20 + ]: # Limit to avoid Discord's 25 option limit + options.append( + discord.SelectOption( + label=f"{move.player.name}", + description=move.description[:100], # Discord description limit + value=str(move.player.id), + emoji="šŸ”„", + ) + ) move_count += 1 # Add supplementary moves if there's room remaining_slots = 25 - move_count for move in builder.trade.supplementary_moves[:remaining_slots]: - options.append(discord.SelectOption( - label=f"{move.player.name}", - description=move.description[:100], - value=str(move.player.id), - emoji="āš™ļø" - )) + options.append( + discord.SelectOption( + label=f"{move.player.name}", + description=move.description[:100], + value=str(move.player.id), + emoji="āš™ļø", + ) + ) super().__init__( placeholder="Select a move to remove...", min_values=1, max_values=1, - options=options + options=options, ) async def callback(self, interaction: discord.Interaction): @@ -241,8 +263,7 @@ class RemoveTradeMovesSelect(discord.ui.Select): if success: await interaction.response.send_message( - f"āœ… Removed move for player ID {player_id}", - ephemeral=True + f"āœ… Removed move for player ID {player_id}", ephemeral=True ) # Update the embed @@ -253,15 +274,16 @@ class RemoveTradeMovesSelect(discord.ui.Select): await interaction.edit_original_response(embed=embed, view=main_view) else: await interaction.response.send_message( - f"āŒ Could not remove move: {error_msg}", - ephemeral=True + f"āŒ Could not remove move: {error_msg}", ephemeral=True ) class SubmitTradeConfirmationModal(discord.ui.Modal): """Modal for confirming trade submission - posts acceptance request to trade channel.""" - def __init__(self, builder: TradeBuilder, trade_channel: Optional[discord.TextChannel] = None): + def __init__( + self, builder: TradeBuilder, trade_channel: Optional[discord.TextChannel] = None + ): super().__init__(title="Confirm Trade Submission") self.builder = builder self.trade_channel = trade_channel @@ -270,7 +292,7 @@ class SubmitTradeConfirmationModal(discord.ui.Modal): label="Type 'CONFIRM' to submit for approval", placeholder="CONFIRM", required=True, - max_length=7 + max_length=7, ) self.add_item(self.confirmation) @@ -280,7 +302,7 @@ class SubmitTradeConfirmationModal(discord.ui.Modal): if self.confirmation.value.upper() != "CONFIRM": await interaction.response.send_message( "āŒ Trade not submitted. You must type 'CONFIRM' exactly.", - ephemeral=True + ephemeral=True, ) return @@ -288,7 +310,6 @@ class SubmitTradeConfirmationModal(discord.ui.Modal): try: # Update trade status to PROPOSED - from models.trade import TradeStatus self.builder.trade.status = TradeStatus.PROPOSED # Create acceptance embed and view @@ -301,7 +322,10 @@ class SubmitTradeConfirmationModal(discord.ui.Modal): # Try to find trade channel by name pattern trade_channel_name = f"trade-{'-'.join(t.abbrev.lower() for t in self.builder.participating_teams)}" for ch in interaction.guild.text_channels: # type: ignore - if ch.name.startswith("trade-") and self.builder.trade_id[:4] in ch.name: + if ( + ch.name.startswith("trade-") + and self.builder.trade_id[:4] in ch.name + ): channel = ch break @@ -310,25 +334,24 @@ class SubmitTradeConfirmationModal(discord.ui.Modal): await channel.send( content="šŸ“‹ **Trade submitted for approval!** All teams must accept to complete the trade.", embed=acceptance_embed, - view=acceptance_view + view=acceptance_view, ) await interaction.followup.send( f"āœ… Trade submitted for approval!\n\nThe acceptance request has been posted to {channel.mention}.\n" f"All participating teams must click **Accept Trade** to finalize.", - ephemeral=True + ephemeral=True, ) else: # No trade channel found, post in current channel await interaction.followup.send( content="šŸ“‹ **Trade submitted for approval!** All teams must accept to complete the trade.", embed=acceptance_embed, - view=acceptance_view + view=acceptance_view, ) except Exception as e: await interaction.followup.send( - f"āŒ Error submitting trade: {str(e)}", - ephemeral=True + f"āŒ Error submitting trade: {str(e)}", ephemeral=True ) @@ -341,10 +364,10 @@ class TradeAcceptanceView(discord.ui.View): async def _get_user_team(self, interaction: discord.Interaction) -> Optional[Team]: """Get the team owned by the interacting user.""" - from services.team_service import team_service - from config import get_config config = get_config() - return await team_service.get_team_by_owner(interaction.user.id, config.sba_season) + return await team_service.get_team_by_owner( + interaction.user.id, config.sba_season + ) async def interaction_check(self, interaction: discord.Interaction) -> bool: """Check if user is a GM of a participating team.""" @@ -352,8 +375,7 @@ class TradeAcceptanceView(discord.ui.View): if not user_team: await interaction.response.send_message( - "āŒ You don't own a team in the league.", - ephemeral=True + "āŒ You don't own a team in the league.", ephemeral=True ) return False @@ -361,8 +383,7 @@ class TradeAcceptanceView(discord.ui.View): participant = self.builder.trade.get_participant_by_organization(user_team) if not participant: await interaction.response.send_message( - "āŒ Your team is not part of this trade.", - ephemeral=True + "āŒ Your team is not part of this trade.", ephemeral=True ) return False @@ -374,8 +395,12 @@ class TradeAcceptanceView(discord.ui.View): if isinstance(item, discord.ui.Button): item.disabled = True - @discord.ui.button(label="Accept Trade", style=discord.ButtonStyle.success, emoji="āœ…") - async def accept_button(self, interaction: discord.Interaction, button: discord.ui.Button): + @discord.ui.button( + label="Accept Trade", style=discord.ButtonStyle.success, emoji="āœ…" + ) + async def accept_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Handle accept button click.""" user_team = await self._get_user_team(interaction) if not user_team: @@ -392,7 +417,7 @@ class TradeAcceptanceView(discord.ui.View): if self.builder.has_team_accepted(team_id): await interaction.response.send_message( f"āœ… {participant.team.abbrev} has already accepted this trade.", - ephemeral=True + ephemeral=True, ) return @@ -413,8 +438,12 @@ class TradeAcceptanceView(discord.ui.View): f"({len(self.builder.accepted_teams)}/{self.builder.team_count} teams)" ) - @discord.ui.button(label="Reject Trade", style=discord.ButtonStyle.danger, emoji="āŒ") - async def reject_button(self, interaction: discord.Interaction, button: discord.ui.Button): + @discord.ui.button( + label="Reject Trade", style=discord.ButtonStyle.danger, emoji="āŒ" + ) + async def reject_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Handle reject button click - moves trade back to DRAFT.""" user_team = await self._get_user_team(interaction) if not user_team: @@ -446,14 +475,6 @@ class TradeAcceptanceView(discord.ui.View): async def _finalize_trade(self, interaction: discord.Interaction) -> None: """Finalize the trade - create transactions and complete.""" - from services.league_service import league_service - from services.transaction_service import transaction_service - from services.trade_builder import clear_trade_builder_by_team - from models.transaction import Transaction - from models.trade import TradeStatus - from utils.transaction_logging import post_trade_to_log - from config import get_config - try: await interaction.response.defer() @@ -469,7 +490,7 @@ class TradeAcceptanceView(discord.ui.View): abbrev="FA", sname="Free Agents", lname="Free Agency", - season=self.builder.trade.season + season=self.builder.trade.season, ) # type: ignore # Create transactions from all moves @@ -482,18 +503,34 @@ class TradeAcceptanceView(discord.ui.View): if move.from_roster == RosterType.MAJOR_LEAGUE: old_team = move.source_team elif move.from_roster == RosterType.MINOR_LEAGUE: - old_team = await move.source_team.minor_league_affiliate() if move.source_team else None + old_team = ( + await move.source_team.minor_league_affiliate() + if move.source_team + else None + ) elif move.from_roster == RosterType.INJURED_LIST: - old_team = await move.source_team.injured_list_affiliate() if move.source_team else None + old_team = ( + await move.source_team.injured_list_affiliate() + if move.source_team + else None + ) else: old_team = move.source_team if move.to_roster == RosterType.MAJOR_LEAGUE: new_team = move.destination_team elif move.to_roster == RosterType.MINOR_LEAGUE: - new_team = await move.destination_team.minor_league_affiliate() if move.destination_team else None + new_team = ( + await move.destination_team.minor_league_affiliate() + if move.destination_team + else None + ) elif move.to_roster == RosterType.INJURED_LIST: - new_team = await move.destination_team.injured_list_affiliate() if move.destination_team else None + new_team = ( + await move.destination_team.injured_list_affiliate() + if move.destination_team + else None + ) else: new_team = move.destination_team @@ -507,7 +544,7 @@ class TradeAcceptanceView(discord.ui.View): oldteam=old_team, newteam=new_team, cancelled=False, - frozen=False # Trades are NOT frozen - immediately effective + frozen=False, # Trades are NOT frozen - immediately effective ) transactions.append(transaction) @@ -516,9 +553,17 @@ class TradeAcceptanceView(discord.ui.View): if move.from_roster == RosterType.MAJOR_LEAGUE: old_team = move.source_team elif move.from_roster == RosterType.MINOR_LEAGUE: - old_team = await move.source_team.minor_league_affiliate() if move.source_team else None + old_team = ( + await move.source_team.minor_league_affiliate() + if move.source_team + else None + ) elif move.from_roster == RosterType.INJURED_LIST: - old_team = await move.source_team.injured_list_affiliate() if move.source_team else None + old_team = ( + await move.source_team.injured_list_affiliate() + if move.source_team + else None + ) elif move.from_roster == RosterType.FREE_AGENCY: old_team = fa_team else: @@ -527,9 +572,17 @@ class TradeAcceptanceView(discord.ui.View): if move.to_roster == RosterType.MAJOR_LEAGUE: new_team = move.destination_team elif move.to_roster == RosterType.MINOR_LEAGUE: - new_team = await move.destination_team.minor_league_affiliate() if move.destination_team else None + new_team = ( + await move.destination_team.minor_league_affiliate() + if move.destination_team + else None + ) elif move.to_roster == RosterType.INJURED_LIST: - new_team = await move.destination_team.injured_list_affiliate() if move.destination_team else None + new_team = ( + await move.destination_team.injured_list_affiliate() + if move.destination_team + else None + ) elif move.to_roster == RosterType.FREE_AGENCY: new_team = fa_team else: @@ -545,13 +598,15 @@ class TradeAcceptanceView(discord.ui.View): oldteam=old_team, newteam=new_team, cancelled=False, - frozen=False # Trades are NOT frozen - immediately effective + frozen=False, # Trades are NOT frozen - immediately effective ) transactions.append(transaction) # POST transactions to database if transactions: - created_transactions = await transaction_service.create_transaction_batch(transactions) + created_transactions = ( + await transaction_service.create_transaction_batch(transactions) + ) else: created_transactions = [] @@ -561,7 +616,7 @@ class TradeAcceptanceView(discord.ui.View): bot=interaction.client, builder=self.builder, transactions=created_transactions, - effective_week=next_week + effective_week=next_week, ) # Update trade status @@ -572,7 +627,9 @@ class TradeAcceptanceView(discord.ui.View): self.reject_button.disabled = True # Update embed to show completion - embed = await create_trade_complete_embed(self.builder, len(created_transactions), next_week) + embed = await create_trade_complete_embed( + self.builder, len(created_transactions), next_week + ) await interaction.edit_original_response(embed=embed, view=self) # Send completion message @@ -591,8 +648,7 @@ class TradeAcceptanceView(discord.ui.View): except Exception as e: await interaction.followup.send( - f"āŒ Error finalizing trade: {str(e)}", - ephemeral=True + f"āŒ Error finalizing trade: {str(e)}", ephemeral=True ) @@ -601,15 +657,17 @@ async def create_trade_acceptance_embed(builder: TradeBuilder) -> discord.Embed: embed = EmbedTemplate.create_base_embed( title=f"šŸ“‹ Trade Pending Acceptance - {builder.trade.get_trade_summary()}", description="All participating teams must accept to complete the trade.", - color=EmbedColors.WARNING + color=EmbedColors.WARNING, ) # Show participating teams - team_list = [f"• {team.abbrev} - {team.sname}" for team in builder.participating_teams] + team_list = [ + f"• {team.abbrev} - {team.sname}" for team in builder.participating_teams + ] embed.add_field( name=f"šŸŸļø Participating Teams ({builder.team_count})", value="\n".join(team_list), - inline=False + inline=False, ) # Show cross-team moves @@ -622,7 +680,7 @@ async def create_trade_acceptance_embed(builder: TradeBuilder) -> discord.Embed: embed.add_field( name=f"šŸ”„ Player Exchanges ({len(builder.trade.cross_team_moves)})", value=moves_text, - inline=False + inline=False, ) # Show supplementary moves if any @@ -635,7 +693,7 @@ async def create_trade_acceptance_embed(builder: TradeBuilder) -> discord.Embed: embed.add_field( name=f"āš™ļø Supplementary Moves ({len(builder.trade.supplementary_moves)})", value=supp_text, - inline=False + inline=False, ) # Show acceptance status @@ -647,25 +705,27 @@ async def create_trade_acceptance_embed(builder: TradeBuilder) -> discord.Embed: status_lines.append(f"ā³ **{team.abbrev}** - Pending") embed.add_field( - name="šŸ“Š Acceptance Status", - value="\n".join(status_lines), - inline=False + name="šŸ“Š Acceptance Status", value="\n".join(status_lines), inline=False ) # Add footer - embed.set_footer(text=f"Trade ID: {builder.trade_id} • {len(builder.accepted_teams)}/{builder.team_count} teams accepted") + embed.set_footer( + text=f"Trade ID: {builder.trade_id} • {len(builder.accepted_teams)}/{builder.team_count} teams accepted" + ) return embed -async def create_trade_rejection_embed(builder: TradeBuilder, rejecting_team: Team) -> discord.Embed: +async def create_trade_rejection_embed( + builder: TradeBuilder, rejecting_team: Team +) -> discord.Embed: """Create embed showing trade was rejected.""" embed = EmbedTemplate.create_base_embed( title=f"āŒ Trade Rejected - {builder.trade.get_trade_summary()}", description=f"**{rejecting_team.abbrev}** has rejected the trade.\n\n" - f"The trade has been moved back to **DRAFT** status.\n" - f"Teams can continue negotiating using `/trade` commands.", - color=EmbedColors.ERROR + f"The trade has been moved back to **DRAFT** status.\n" + f"Teams can continue negotiating using `/trade` commands.", + color=EmbedColors.ERROR, ) embed.set_footer(text=f"Trade ID: {builder.trade_id}") @@ -673,22 +733,22 @@ async def create_trade_rejection_embed(builder: TradeBuilder, rejecting_team: Te return embed -async def create_trade_complete_embed(builder: TradeBuilder, transaction_count: int, effective_week: int) -> discord.Embed: +async def create_trade_complete_embed( + builder: TradeBuilder, transaction_count: int, effective_week: int +) -> discord.Embed: """Create embed showing trade was completed.""" embed = EmbedTemplate.create_base_embed( title=f"šŸŽ‰ Trade Complete! - {builder.trade.get_trade_summary()}", description=f"All {builder.team_count} teams have accepted the trade!\n\n" - f"**{transaction_count} transactions** created for **Week {effective_week}**.", - color=EmbedColors.SUCCESS + f"**{transaction_count} transactions** created for **Week {effective_week}**.", + color=EmbedColors.SUCCESS, ) # Show final acceptance status (all green) - status_lines = [f"āœ… **{team.abbrev}** - Accepted" for team in builder.participating_teams] - embed.add_field( - name="šŸ“Š Final Status", - value="\n".join(status_lines), - inline=False - ) + status_lines = [ + f"āœ… **{team.abbrev}** - Accepted" for team in builder.participating_teams + ] + embed.add_field(name="šŸ“Š Final Status", value="\n".join(status_lines), inline=False) # Show cross-team moves if builder.trade.cross_team_moves: @@ -697,13 +757,11 @@ async def create_trade_complete_embed(builder: TradeBuilder, transaction_count: moves_text += f"• {move.description}\n" if len(builder.trade.cross_team_moves) > 8: moves_text += f"... and {len(builder.trade.cross_team_moves) - 8} more" - embed.add_field( - name=f"šŸ”„ Player Exchanges", - value=moves_text, - inline=False - ) + embed.add_field(name=f"šŸ”„ Player Exchanges", value=moves_text, inline=False) - embed.set_footer(text=f"Trade ID: {builder.trade_id} • Effective: Week {effective_week}") + embed.set_footer( + text=f"Trade ID: {builder.trade_id} • Effective: Week {effective_week}" + ) return embed @@ -728,15 +786,17 @@ async def create_trade_embed(builder: TradeBuilder) -> discord.Embed: embed = EmbedTemplate.create_base_embed( title=f"šŸ“‹ Trade Builder - {builder.trade.get_trade_summary()}", description=f"Build your multi-team trade", - color=color + color=color, ) # Add participating teams section - team_list = [f"• {team.abbrev} - {team.sname}" for team in builder.participating_teams] + team_list = [ + f"• {team.abbrev} - {team.sname}" for team in builder.participating_teams + ] embed.add_field( name=f"šŸŸļø Participating Teams ({builder.team_count})", value="\n".join(team_list) if team_list else "*No teams yet*", - inline=False + inline=False, ) # Add current moves section @@ -744,13 +804,15 @@ async def create_trade_embed(builder: TradeBuilder) -> discord.Embed: embed.add_field( name="Current Moves", value="*No moves yet. Use the `/trade` commands to build your trade.*", - inline=False + inline=False, ) else: # Show cross-team moves if builder.trade.cross_team_moves: moves_text = "" - for i, move in enumerate(builder.trade.cross_team_moves[:8], 1): # Limit display + for i, move in enumerate( + builder.trade.cross_team_moves[:8], 1 + ): # Limit display moves_text += f"{i}. {move.description}\n" if len(builder.trade.cross_team_moves) > 8: @@ -759,22 +821,26 @@ async def create_trade_embed(builder: TradeBuilder) -> discord.Embed: embed.add_field( name=f"šŸ”„ Player Exchanges ({len(builder.trade.cross_team_moves)})", value=moves_text, - inline=False + inline=False, ) # Show supplementary moves if builder.trade.supplementary_moves: supp_text = "" - for i, move in enumerate(builder.trade.supplementary_moves[:5], 1): # Limit display + for i, move in enumerate( + builder.trade.supplementary_moves[:5], 1 + ): # Limit display supp_text += f"{i}. {move.description}\n" if len(builder.trade.supplementary_moves) > 5: - supp_text += f"... and {len(builder.trade.supplementary_moves) - 5} more" + supp_text += ( + f"... and {len(builder.trade.supplementary_moves) - 5} more" + ) embed.add_field( name=f"āš™ļø Supplementary Moves ({len(builder.trade.supplementary_moves)})", value=supp_text, - inline=False + inline=False, ) # Add quick validation summary @@ -785,20 +851,18 @@ async def create_trade_embed(builder: TradeBuilder) -> discord.Embed: error_count = len(validation.all_errors) status_text = f"āŒ {error_count} error{'s' if error_count != 1 else ''} found" - embed.add_field( - name="šŸ” Quick Status", - value=status_text, - inline=False - ) + embed.add_field(name="šŸ” Quick Status", value=status_text, inline=False) # Add instructions for adding more moves embed.add_field( name="āž• Build Your Trade", value="• `/trade add-player` - Add player exchanges\n• `/trade supplementary` - Add internal moves\n• `/trade add-team` - Add more teams", - inline=False + inline=False, ) # Add footer with trade ID and timestamp - embed.set_footer(text=f"Trade ID: {builder.trade_id} • Created: {datetime.fromisoformat(builder.trade.created_at).strftime('%H:%M:%S')}") + embed.set_footer( + text=f"Trade ID: {builder.trade_id} • Created: {datetime.fromisoformat(builder.trade.created_at).strftime('%H:%M:%S')}" + ) - return embed \ No newline at end of file + return embed From c961ea0dea5a7e69b84c78400b471feef2ed4b11 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 2 Mar 2026 13:42:18 -0600 Subject: [PATCH 2/2] docs: clarify git branching workflow in CLAUDE.md Branch from next-release for normal work, main only for hotfixes. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3e1a819..a559bcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,10 +16,15 @@ manticorum67/major-domo-discordapp There is NO DASH between "discord" and "app". Not `discord-app`, not `discordapp-v2`. ### Git Workflow -NEVER commit directly to `main`. Always use feature branches: +NEVER commit directly to `main` or `next-release`. Always use feature branches. + +**Branch from `next-release`** for normal work targeting the next release: ```bash -git checkout -b feature/name # or fix/name +git checkout -b feature/name origin/next-release # or fix/name, refactor/name ``` +**Branch from `main`** only for urgent hotfixes that bypass the release cycle. + +PRs go to `next-release` (staging), then `next-release → main` when releasing. ### Double Emoji in Embeds `EmbedTemplate.success/error/warning/info/loading()` auto-add emoji prefixes.