diff --git a/COMMAND_LIST.md b/COMMAND_LIST.md new file mode 100644 index 0000000..9e1f228 --- /dev/null +++ b/COMMAND_LIST.md @@ -0,0 +1,386 @@ +# Discord Bot v2.0 - Complete Command List + +**Generated:** January 2025 +**Bot Version:** 2.0 +**Total Commands:** 55+ slash commands + +--- + +## 📊 League Information Commands + +### `/league` +Display current league status and information + +### `/standings` +Display league standings + +### `/playoff-picture` +Display current playoff picture + +### `/schedule` +Display game schedule + +### `/results` +Display recent game results + +--- + +## 👥 Player Commands + +### `/player ` +Display player information and statistics +- **Parameters:** Player name (autocomplete enabled) + +--- + +## 🏟️ Team Commands + +### `/team [season]` +Display team information +- **Parameters:** + - `abbrev`: Team abbreviation (e.g., NYY, BOS, LAD) + - `season`: Season to show (optional, defaults to current) + +### `/teams [season]` +List all teams in a season +- **Parameters:** + - `season`: Season to list (optional, defaults to current) + +### `/roster [roster_type]` +Display team roster +- **Parameters:** + - `abbrev`: Team abbreviation + - `roster_type`: Current or Next week (optional) + +--- + +## 🔄 Transaction Commands + +### `/mymoves` +View your pending and scheduled transactions + +### `/legal` +Check roster legality for current and next week + +### `/dropadd` +Build a transaction for next week + +### `/cleartransaction` +Clear your current transaction builder + +--- + +## 🤝 Trade Commands + +### `/trade initiate ` +Start a new trade with another team +- **Parameters:** + - `other_team`: Team abbreviation (autocomplete enabled) +- **Creates:** Dedicated trade discussion channel + +### `/trade add-team ` +Add another team to your current trade (for 3+ team trades) +- **Parameters:** + - `other_team`: Team abbreviation (autocomplete enabled) + +### `/trade add-player ` +Add a player to the trade +- **Parameters:** + - `player_name`: Player name (autocomplete enabled) + - `destination_team`: Team abbreviation (autocomplete enabled) + +### `/trade supplementary ` +Add a supplementary move within your organization for roster legality +- **Parameters:** + - `player_name`: Player name (autocomplete enabled) + - `destination`: Major League, Minor League, or Free Agency + +### `/trade view` +View your current trade + +### `/trade clear` +Clear your current trade and delete associated channel + +--- + +## 🎲 Dice Rolling Commands + +### `/roll ` +Roll polyhedral dice using XdY notation (e.g., 2d6, 1d20, 3d8) +- **Parameters:** + - `dice`: Dice notation (e.g., "2d6", "1d6;2d6;1d20") + +### `/ab` +Roll baseball at-bat dice (1d6;2d6;1d20) + +### `/scout ` +Roll weighted scouting dice (1d6;2d6;1d20) based on card type +- **Parameters:** + - `card_type`: Batter (1-3 first d6) or Pitcher (4-6 first d6) + +### `/fielding ` +Roll Super Advanced fielding dice for a defensive position +- **Parameters:** + - `position`: C, 1B, 2B, 3B, SS, LF, CF, RF + +--- + +## ⚙️ Utility Commands + +### `/weather [team_abbrev]` +Roll ballpark weather for a team +- **Parameters:** + - `team_abbrev`: Team abbreviation (optional, auto-detects from channel/user) + +### `/charts ` +Display a gameplay chart or infographic +- **Parameters:** + - `chart_name`: Name of chart (autocomplete enabled) + +--- + +## 🗣️ Voice Channel Commands + +### `/voice-channel public` +Create a public voice channel for gameplay +- **Auto-cleanup:** Deletes after 15 minutes of being empty + +### `/voice-channel private` +Create a private team vs team voice channel +- **Permissions:** Only team members can speak, others can listen +- **Auto-detection:** Automatically finds your opponent from schedule +- **Auto-cleanup:** Deletes after 15 minutes of being empty + +--- + +## 📝 Custom Commands + +### `/cc ` +Execute a custom command +- **Parameters:** + - `name`: Name of custom command to execute + +### `/cc-create` +Create a new custom command +- Opens interactive modal for input + +### `/cc-edit ` +Edit one of your custom commands +- **Parameters:** + - `name`: Name of command to edit + +### `/cc-delete ` +Delete one of your custom commands +- **Parameters:** + - `name`: Name of command to delete + +### `/cc-mine` +View and manage your custom commands + +### `/cc-list` +Browse all custom commands + +### `/cc-search` +Advanced search for custom commands + +### `/cc-info ` +Get detailed information about a custom command +- **Parameters:** + - `name`: Name of command + +--- + +## 📚 Help System Commands + +### `/help [topic]` +View help topics or list all available help +- **Parameters:** + - `topic`: Specific help topic (optional, autocomplete enabled) + +### `/help-create` +Create a new help topic (admin/help editor only) +- Opens interactive modal for input + +### `/help-edit ` +Edit an existing help topic (admin/help editor only) +- **Parameters:** + - `topic`: Topic name to edit (autocomplete enabled) + +### `/help-delete ` +Delete a help topic (admin/help editor only) +- **Parameters:** + - `topic`: Topic name to delete (autocomplete enabled) + +### `/help-list [category] [show_deleted]` +Browse all help topics +- **Parameters:** + - `category`: Filter by category (optional) + - `show_deleted`: Include deleted topics (optional, admin only) + +--- + +## 🖼️ Profile Management Commands + +### `/set-image ` +Update a player's fancy card or headshot image +- **Parameters:** + - `image_type`: Fancy Card or Headshot + - `player_name`: Player name (autocomplete enabled) + - `image_url`: URL to image +- **Permissions:** + - Regular users: Can update players in their organization (ML/MiL/IL) + - Administrators: Can update any player +- **Validation:** Checks URL accessibility and content-type + +--- + +## 🎭 Meme Commands + +### `/lastsoak` +Get information about the last soak mention +- Displays last player to say the forbidden word +- Shows disappointment GIF +- Tracks total mentions + +--- + +## 🔧 Admin Commands + +### `/admin-status` +Display bot status and system information + +### `/admin-help` +Display available admin commands and their usage + +### `/admin-reload ` +Reload a specific bot cog +- **Parameters:** + - `cog`: Name of cog to reload + +### `/admin-sync` +Sync application commands with Discord + +### `/admin-clear ` +Clear messages from the current channel +- **Parameters:** + - `amount`: Number of messages to clear + +### `/admin-announce ` +Send an announcement to the current channel +- **Parameters:** + - `message`: Announcement text + +### `/admin-maintenance` +Toggle maintenance mode for the bot + +### `/admin-timeout [reason]` +Timeout a user for a specified duration +- **Parameters:** + - `user`: User to timeout + - `duration`: Duration (e.g., "10m", "1h", "1d") + - `reason`: Optional reason + +### `/admin-untimeout ` +Remove timeout from a user +- **Parameters:** + - `user`: User to remove timeout from + +### `/admin-kick [reason]` +Kick a user from the server +- **Parameters:** + - `user`: User to kick + - `reason`: Optional reason + +### `/admin-ban [reason]` +Ban a user from the server +- **Parameters:** + - `user`: User to ban + - `reason`: Optional reason + +### `/admin-unban ` +Unban a user from the server +- **Parameters:** + - `user_id`: Discord user ID to unban + +### `/admin-userinfo ` +Display detailed information about a user +- **Parameters:** + - `user`: User to get info about + +--- + +## 🛠️ Admin - Chart Management Commands + +### `/chart-add [description]` +[Admin] Add a new chart to the library +- **Parameters:** + - `key`: Unique identifier (e.g., 'rest', 'sac-bunt') + - `name`: Display name + - `category`: gameplay, defense, reference, stats + - `url`: Image URL + - `description`: Optional description + +### `/chart-remove ` +[Admin] Remove a chart from the library +- **Parameters:** + - `key`: Chart key to remove + +### `/chart-list [category]` +[Admin] List all available charts +- **Parameters:** + - `category`: Filter by category (optional) + +### `/chart-update [name] [category] [url] [description]` +[Admin] Update a chart's properties +- **Parameters:** + - `key`: Chart key to update + - All other parameters are optional updates + +--- + +## 📊 Command Statistics + +- **Total Slash Commands:** 55+ +- **Command Groups:** 2 (`/voice-channel`, `/trade`) +- **Admin Commands:** 16 +- **User Commands:** 39+ +- **Autocomplete Enabled:** 15+ commands +- **Interactive Modals:** 4 commands (cc-create, cc-edit, help-create, help-edit) + +--- + +## 🎯 Key Features + +### Autocomplete Support +Commands with autocomplete for better UX: +- Player names +- Team abbreviations +- Chart names +- Help topics +- Custom command names + +### Interactive Features +- Trade builder with real-time validation +- Trade discussion channels +- Custom command modals +- Help topic modals +- Confirmation dialogs + +### Auto-cleanup Services +- Voice channels (15 min empty threshold) +- Trade discussion channels (on trade clear) + +### Permission System +- User-level permissions (own organization players) +- Admin-level permissions (full access) +- Role-based permissions (Help Editor role) + +--- + +## 📝 Notes + +- All commands use modern Discord slash command syntax (`/command`) +- Deprecated prefix commands (`!command`) show migration messages +- Most commands use ephemeral responses for privacy +- Comprehensive error handling and validation +- Full logging with trace IDs for debugging diff --git a/PRE_LAUNCH_ROADMAP.md b/PRE_LAUNCH_ROADMAP.md index 6bfc2bb..3625912 100644 --- a/PRE_LAUNCH_ROADMAP.md +++ b/PRE_LAUNCH_ROADMAP.md @@ -56,7 +56,7 @@ This document outlines the remaining functionality required before the Discord B - Admin commands for chart management (add, remove, list, update) - Category organization (gameplay, defense, reference, stats) - Proper embed formatting with descriptions -- **Data Storage**: `storage/charts.json` with JSON persistence +- **Data Storage**: `data/charts.json` with JSON persistence - **Completed**: January 2025 #### 4. Custom Help System **✅ COMPLETED** diff --git a/commands/league/info.py b/commands/league/info.py index a2e3113..c4ba9ad 100644 --- a/commands/league/info.py +++ b/commands/league/info.py @@ -22,8 +22,8 @@ class LeagueInfoCommands(commands.Cog): self.logger = get_contextual_logger(f'{__name__}.LeagueInfoCommands') self.logger.info("LeagueInfoCommands cog initialized") - @discord.app_commands.command(name="league", description="Display current league status and information") - @logged_command("/league") + @discord.app_commands.command(name="league-metadata", description="Display current league metadata") + @logged_command("/league-metadata") async def league_info(self, interaction: discord.Interaction): """Display current league state and information.""" await interaction.response.defer() @@ -41,8 +41,8 @@ class LeagueInfoCommands(commands.Cog): # Create league info embed embed = EmbedTemplate.create_base_embed( - title="🏆 SBA League Status", - description="Current league information and status" + title="🏆 SBA League Metadata", + description="Current league metadata" ) # Basic league info diff --git a/commands/league/schedule.py b/commands/league/schedule.py index eb0fb3c..9ac062a 100644 --- a/commands/league/schedule.py +++ b/commands/league/schedule.py @@ -55,53 +55,53 @@ class ScheduleCommands(commands.Cog): # Show recent/upcoming games await self._show_current_schedule(interaction, search_season) - @discord.app_commands.command( - name="results", - description="Display recent game results" - ) - @discord.app_commands.describe( - season="Season to show results for (defaults to current season)", - week="Specific week to show results for (optional)" - ) - @logged_command("/results") - async def results( - self, - interaction: discord.Interaction, - season: Optional[int] = None, - week: Optional[int] = None - ): - """Display recent game results.""" - await interaction.response.defer() + # @discord.app_commands.command( + # name="results", + # description="Display recent game results" + # ) + # @discord.app_commands.describe( + # season="Season to show results for (defaults to current season)", + # week="Specific week to show results for (optional)" + # ) + # @logged_command("/results") + # async def results( + # self, + # interaction: discord.Interaction, + # season: Optional[int] = None, + # week: Optional[int] = None + # ): + # """Display recent game results.""" + # await interaction.response.defer() - search_season = season or SBA_CURRENT_SEASON + # search_season = season or SBA_CURRENT_SEASON - if week: - # Show specific week results - games = await schedule_service.get_week_schedule(search_season, week) - completed_games = [game for game in games if game.is_completed] + # if week: + # # Show specific week results + # games = await schedule_service.get_week_schedule(search_season, week) + # completed_games = [game for game in games if game.is_completed] - if not completed_games: - await interaction.followup.send( - f"❌ No completed games found for season {search_season}, week {week}.", - ephemeral=True - ) - return + # if not completed_games: + # await interaction.followup.send( + # f"❌ No completed games found for season {search_season}, week {week}.", + # ephemeral=True + # ) + # return - embed = await self._create_week_results_embed(completed_games, search_season, week) - await interaction.followup.send(embed=embed) - else: - # Show recent results - recent_games = await schedule_service.get_recent_games(search_season) + # embed = await self._create_week_results_embed(completed_games, search_season, week) + # await interaction.followup.send(embed=embed) + # else: + # # Show recent results + # recent_games = await schedule_service.get_recent_games(search_season) - if not recent_games: - await interaction.followup.send( - f"❌ No recent games found for season {search_season}.", - ephemeral=True - ) - return + # if not recent_games: + # await interaction.followup.send( + # f"❌ No recent games found for season {search_season}.", + # ephemeral=True + # ) + # return - embed = await self._create_recent_results_embed(recent_games, search_season) - await interaction.followup.send(embed=embed) + # embed = await self._create_recent_results_embed(recent_games, search_season) + # await interaction.followup.send(embed=embed) async def _show_week_schedule(self, interaction: discord.Interaction, season: int, week: int): """Show schedule for a specific week.""" diff --git a/commands/league/standings.py b/commands/league/standings.py index a62c985..2fe2f57 100644 --- a/commands/league/standings.py +++ b/commands/league/standings.py @@ -172,23 +172,23 @@ class StandingsCommands(commands.Cog): inline=False ) - # Add additional stats for top teams - if len(teams) >= 3: - stats_lines = [] - for team in teams[:3]: # Top 3 teams - stats_line = ( - f"**{team.team.abbrev}**: " - f"Home {team.home_record} • " - f"Last 8: {team.last8_record} • " - f"Streak: {team.current_streak}" - ) - stats_lines.append(stats_line) + # # Add additional stats for top teams + # if len(teams) >= 3: + # stats_lines = [] + # for team in teams[:3]: # Top 3 teams + # stats_line = ( + # f"**{team.team.abbrev}**: " + # f"Home {team.home_record} • " + # f"Last 8: {team.last8_record} • " + # f"Streak: {team.current_streak}" + # ) + # stats_lines.append(stats_line) - embed.add_field( - name="Recent Form (Top 3)", - value="\n".join(stats_lines), - inline=False - ) + # embed.add_field( + # name="Recent Form (Top 3)", + # value="\n".join(stats_lines), + # inline=False + # ) embed.set_footer(text=f"Season {season}") return embed diff --git a/commands/soak/tracker.py b/commands/soak/tracker.py index 9aaa1b3..f084a6a 100644 --- a/commands/soak/tracker.py +++ b/commands/soak/tracker.py @@ -22,7 +22,7 @@ class SoakTracker: - Time-based calculations for disappointment tiers """ - def __init__(self, data_file: str = "storage/soak_data.json"): + def __init__(self, data_file: str = "data/soak_data.json"): """ Initialize the soak tracker. diff --git a/commands/teams/info.py b/commands/teams/info.py index 6e705c2..a4a52f9 100644 --- a/commands/teams/info.py +++ b/commands/teams/info.py @@ -8,12 +8,13 @@ import discord from discord.ext import commands from services import team_service, player_service -from models.team import Team +from models.team import RosterType, Team from constants import SBA_CURRENT_SEASON from utils.logging import get_contextual_logger from utils.decorators import logged_command from exceptions import BotException from views.embeds import EmbedTemplate, EmbedColors +from views.base import PaginationView class TeamInfoCommands(commands.Cog): @@ -65,11 +66,11 @@ class TeamInfoCommands(commands.Cog): async def list_teams(self, interaction: discord.Interaction, season: Optional[int] = None): """List all teams in a season.""" await interaction.response.defer() - + season = season or SBA_CURRENT_SEASON - + teams = await team_service.get_teams_by_season(season) - + if not teams: embed = EmbedTemplate.error( title="No Teams Found", @@ -77,38 +78,55 @@ class TeamInfoCommands(commands.Cog): ) await interaction.followup.send(embed=embed) return - - # Sort teams by abbreviation - teams.sort(key=lambda t: t.abbrev) - - # Create embed with team list - embed = EmbedTemplate.create_base_embed( - title=f"SBA Teams - Season {season}", - color=EmbedColors.PRIMARY - ) - - # Group teams by division if available - if any(team.division_id for team in teams): - divisions = {} - for team in teams: - div_id = team.division_id or 0 - if div_id not in divisions: - divisions[div_id] = [] - divisions[div_id].append(team) - - for div_id, div_teams in sorted(divisions.items()): - div_name = f"Division {div_id}" if div_id > 0 else "Unassigned" - team_list = "\n".join([f"**{team.abbrev}** - {team.lname}" for team in div_teams]) - embed.add_field(name=div_name, value=team_list, inline=True) + + # Filter to major league teams only and sort by abbreviation + ml_teams = [team for team in teams if team.roster_type() == RosterType.MAJOR_LEAGUE] + ml_teams.sort(key=lambda t: t.abbrev) + + if not ml_teams: + embed = EmbedTemplate.error( + title="No Major League Teams Found", + description=f"No major league teams found for season {season}" + ) + await interaction.followup.send(embed=embed) + return + + # Create paginated embeds (12 teams per page to stay under character limit) + teams_per_page = 12 + pages: list[discord.Embed] = [] + + for i in range(0, len(ml_teams), teams_per_page): + page_teams = ml_teams[i:i + teams_per_page] + + embed = EmbedTemplate.create_base_embed( + title=f"SBA Teams - Season {season}", + color=EmbedColors.PRIMARY + ) + + for team in page_teams: + embed.add_field( + name=f'{team}', + value=self._team_detail_description(team), + inline=False + ) + + embed.set_footer(text=f"Total: {len(ml_teams)} teams") + pages.append(embed) + + # Use pagination if multiple pages, otherwise send single embed + if len(pages) > 1: + pagination = PaginationView( + pages=pages, + user_id=interaction.user.id, + show_page_numbers=True + ) + await interaction.followup.send(embed=pagination.get_current_embed(), view=pagination) else: - # Simple list if no divisions - team_list = "\n".join([f"**{team.abbrev}** - {team.lname}" for team in teams]) - embed.add_field(name="Teams", value=team_list, inline=False) - - embed.set_footer(text=f"Total: {len(teams)} teams") - - await interaction.followup.send(embed=embed) + await interaction.followup.send(embed=pages[0]) + def _team_detail_description(self, team: Team) -> str: + return f'GM: {team.gm_names()}\nID: {team.id}' + async def _create_team_embed(self, team: Team, standings_data: Optional[dict] = None) -> discord.Embed: """Create a rich embed for team information.""" embed = EmbedTemplate.create_base_embed( diff --git a/commands/teams/roster.py b/commands/teams/roster.py index 80ed2b7..2890106 100644 --- a/commands/teams/roster.py +++ b/commands/teams/roster.py @@ -7,6 +7,7 @@ from typing import Optional, Dict, Any, List import discord from discord.ext import commands +from models.player import Player from services import team_service, player_service from models.team import Team from constants import SBA_CURRENT_SEASON @@ -26,7 +27,7 @@ class TeamRosterCommands(commands.Cog): @discord.app_commands.command(name="roster", description="Display team roster") @discord.app_commands.describe( - abbrev="Team abbreviation (e.g., NYY, BOS, LAD)", + abbrev="Team abbreviation (e.g., BSG, DEN, WV, etc.)", roster_type="Roster week: current or next (defaults to current)" ) @discord.app_commands.choices(roster_type=[ @@ -77,64 +78,51 @@ class TeamRosterCommands(commands.Cog): # Main roster embed embed = EmbedTemplate.create_base_embed( - title=f"{team.abbrev} - {roster_type.title()} Roster", - description=f"{team.lname} roster breakdown", + title=f"{team.abbrev} - {roster_type.title()} Week", + description=f"{team.lname} Roster Breakdown", color=int(team.color, 16) if team.color else EmbedColors.PRIMARY ) # Position counts for active roster - if 'active' in roster_data: - active_roster = roster_data['active'] - - # Batting positions - batting_positions = ['C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH'] - batting_counts = [] - for pos in batting_positions: - count = active_roster.get(pos, 0) - batting_counts.append(f"**{pos}:** {count}") - - # Pitching positions - pitching_positions = ['SP', 'RP', 'CP'] - pitching_counts = [] - for pos in pitching_positions: - count = active_roster.get(pos, 0) - pitching_counts.append(f"**{pos}:** {count}") - - # Add position count fields - embed.add_field( - name="Batting Positions", - value="\n".join(batting_counts), - inline=True - ) - embed.add_field( - name="Pitching Positions", - value="\n".join(pitching_counts), - inline=True - ) - - # Total WAR - total_war = active_roster.get('WARa', 0) - embed.add_field( - name="Total sWAR", - value=f"{total_war:.1f}" if isinstance(total_war, (int, float)) else str(total_war), - inline=True - ) - - # Add injury list summaries - if 'shortil' in roster_data and roster_data['shortil']: - short_il_count = len(roster_data['shortil'].get('players', [])) - embed.add_field(name="Minor League", value=f"{short_il_count} players", inline=True) - - if 'longil' in roster_data and roster_data['longil']: - long_il_count = len(roster_data['longil'].get('players', [])) - embed.add_field(name="Injured List", value=f"{long_il_count} players", inline=True) + for key in ['active', 'longil', 'shortil']: + if key in roster_data: + this_roster = roster_data[key] + + players = this_roster.get('players') + if len(players) > 0: + this_team = players[0].get("team", {"id": "Unknown", "sname": "Unknown"}) + + embed.add_field( + name='Team (ID)', + value=f'{this_team.get("sname")} ({this_team.get("id")})', + inline=True + ) + + embed.add_field( + name='Player Count', + value=f'{len(players)} Players' + ) + + # Total WAR + total_war = this_roster.get('WARa', 0) + embed.add_field( + name="Total sWAR", + value=f"{total_war:.2f}" if isinstance(total_war, (int, float)) else str(total_war), + inline=True + ) + + embed.add_field( + name='Position Counts', + value=self._position_code_block(this_roster), + inline=False + ) embeds.append(embed) # Create detailed player list embeds if there are players for roster_name, roster_info in roster_data.items(): - if roster_name in ['active', 'shortil', 'longil'] and 'players' in roster_info: - players = roster_info['players'] + if roster_name in ['active', 'longil', 'shortil'] and 'players' in roster_info: + players = sorted(roster_info['players'], key=lambda player: player.get('wara', 0), reverse=True) if players: player_embed = self._create_player_list_embed( team, roster_name, players @@ -143,13 +131,20 @@ class TeamRosterCommands(commands.Cog): return embeds + def _position_code_block(self, roster_data: dict) -> str: + return f'```\n C 1B 2B 3B SS\n' \ + f' {roster_data.get("C", 0)} {roster_data.get("1B", 0)} {roster_data.get("2B", 0)} ' \ + f'{roster_data.get("3B", 0)} {roster_data.get("SS", 0)}\n\nLF CF RF SP RP\n' \ + f' {roster_data.get("LF", 0)} {roster_data.get("CF", 0)} {roster_data.get("RF", 0)} ' \ + f'{roster_data.get("SP", 0)} {roster_data.get("RP", 0)}\n```' + def _create_player_list_embed(self, team: Team, roster_name: str, players: List[Dict[str, Any]]) -> discord.Embed: """Create an embed with detailed player list.""" roster_titles = { 'active': 'Active Roster', - 'shortil': 'Minor League', - 'longil': 'Injured List' + 'longil': 'Minor League', + 'shortil': 'Injured List' } embed = EmbedTemplate.create_base_embed( @@ -162,33 +157,28 @@ class TeamRosterCommands(commands.Cog): pitchers = [] for player in players: - name = player.get('name', 'Unknown') - positions = player.get('positions', []) - war = player.get('WARa', 0) - - # Format WAR display - war_str = f"{war:.1f}" if isinstance(war, (int, float)) else str(war) - - # Determine if pitcher or batter - is_pitcher = any(pos in ['SP', 'RP', 'CP'] for pos in positions) - - player_line = f"**{name}** ({'/'.join(positions)}) - WAR: {war_str}" - - if is_pitcher: - pitchers.append(player_line) - else: - batters.append(player_line) + try: + this_player = Player.from_api_data(player) + player_line = f"{this_player} - sWAR: {this_player.wara}" + + if this_player.is_pitcher: + pitchers.append(player_line) + else: + batters.append(player_line) + except Exception as e: + self.logger.warning(f"Failed to create player from data: {e}", player_id=player.get('id')) # Add player lists to embed if batters: # Split long lists into multiple fields if needed - batter_chunks = self._chunk_list(batters, 10) + batter_chunks = self._chunk_list(batters, 16) for i, chunk in enumerate(batter_chunks): field_name = "Batters" if i == 0 else f"Batters (cont.)" - embed.add_field(name=field_name, value="\n".join(chunk), inline=False) + embed.add_field(name=field_name, value="\n".join(chunk), inline=True) + embed.add_field(name='', value='', inline=False) if pitchers: - pitcher_chunks = self._chunk_list(pitchers, 10) + pitcher_chunks = self._chunk_list(pitchers, 16) for i, chunk in enumerate(pitcher_chunks): field_name = "Pitchers" if i == 0 else f"Pitchers (cont.)" embed.add_field(name=field_name, value="\n".join(chunk), inline=False) diff --git a/commands/transactions/trade_channel_tracker.py b/commands/transactions/trade_channel_tracker.py index 9bd41b3..58b6ff3 100644 --- a/commands/transactions/trade_channel_tracker.py +++ b/commands/transactions/trade_channel_tracker.py @@ -27,7 +27,7 @@ class TradeChannelTracker: - Automatic stale entry removal """ - def __init__(self, data_file: str = "storage/trade_channels.json"): + def __init__(self, data_file: str = "data/trade_channels.json"): """ Initialize the trade channel tracker. diff --git a/commands/utilities/README.md b/commands/utilities/README.md index 6b5ce5f..5e0d54a 100644 --- a/commands/utilities/README.md +++ b/commands/utilities/README.md @@ -163,7 +163,7 @@ Administrators can manage the chart library using these commands: - **Files**: - `commands/utilities/charts.py` - Command handlers - `services/chart_service.py` - Chart management service - - `storage/charts.json` - Chart definitions storage + - `data/charts.json` - Chart definitions storage - **Service**: `ChartService` - Manages chart loading, saving, and retrieval - **Categories**: gameplay, defense, reference, stats - **Logging**: Uses `@logged_command` decorator for automatic logging @@ -194,7 +194,7 @@ Administrators can manage the chart library using these commands: → Shows all gameplay charts ``` -**Data Structure** (`storage/charts.json`): +**Data Structure** (`data/charts.json`): ```json { "charts": { diff --git a/commands/utilities/__init__.py b/commands/utilities/__init__.py index 29dc0e1..08850c4 100644 --- a/commands/utilities/__init__.py +++ b/commands/utilities/__init__.py @@ -7,9 +7,9 @@ import logging from discord.ext import commands from .weather import WeatherCommands -from .charts import ChartCommands, ChartAdminCommands +from .charts import ChartCommands, ChartManageGroup, ChartCategoryGroup -__all__ = ['WeatherCommands', 'ChartCommands', 'ChartAdminCommands', 'setup_utilities'] +__all__ = ['WeatherCommands', 'ChartCommands', 'ChartManageGroup', 'ChartCategoryGroup', 'setup_utilities'] logger = logging.getLogger(__name__) @@ -28,10 +28,10 @@ async def setup_utilities(bot: commands.Bot) -> tuple[int, int, list[str]]: failed = 0 failed_modules = [] + # Cogs that need bot instance cog_classes = [ WeatherCommands, ChartCommands, - ChartAdminCommands, ] for cog_class in cog_classes: @@ -44,4 +44,20 @@ async def setup_utilities(bot: commands.Bot) -> tuple[int, int, list[str]]: failed += 1 failed_modules.append(cog_class.__name__) + # Command groups (added directly to command tree) + command_groups = [ + ChartManageGroup, + ChartCategoryGroup, + ] + + for group_class in command_groups: + try: + bot.tree.add_command(group_class()) + logger.info(f"Loaded command group: {group_class.__name__}") + successful += 1 + except Exception as e: + logger.error(f"Failed to load command group {group_class.__name__}: {e}", exc_info=True) + failed += 1 + failed_modules.append(group_class.__name__) + return successful, failed, failed_modules diff --git a/commands/utilities/charts.py b/commands/utilities/charts.py index 77330fb..4c7dcac 100644 --- a/commands/utilities/charts.py +++ b/commands/utilities/charts.py @@ -14,6 +14,76 @@ from utils.logging import get_contextual_logger, set_discord_context from services.chart_service import get_chart_service, Chart from views.embeds import EmbedTemplate, EmbedColors from exceptions import BotException +from constants import HELP_EDITOR_ROLE_NAME + + +# Standalone autocomplete functions + +async def chart_autocomplete( + interaction: discord.Interaction, + current: str +) -> List[app_commands.Choice[str]]: + """Autocomplete for chart names.""" + chart_service = get_chart_service() + chart_keys = chart_service.get_chart_keys() + + # Filter based on current input + filtered = [ + key for key in chart_keys + if current.lower() in key.lower() + ][:25] # Discord limit + + # Get chart objects for display names + choices = [] + for key in filtered: + chart = chart_service.get_chart(key) + if chart: + choices.append( + app_commands.Choice( + name=f"{chart.name} ({chart.category})", + value=key + ) + ) + + return choices + + +async def category_autocomplete( + interaction: discord.Interaction, + current: str +) -> List[app_commands.Choice[str]]: + """Autocomplete for category keys.""" + chart_service = get_chart_service() + categories = chart_service.get_categories() + + # Filter based on current input + filtered = [ + key for key in categories.keys() + if current.lower() in key.lower() + ][:25] # Discord limit + + return [ + app_commands.Choice( + name=f"{categories[key]} ({key})", + value=key + ) + for key in filtered + ] + + +# Helper function for permission checking +def has_manage_permission(interaction: discord.Interaction) -> bool: + """Check if user has permission to manage charts/categories.""" + # Check if user is admin + if interaction.user.guild_permissions.administrator: + return True + + # Check if user has the Help Editor role + help_editor_role = discord.utils.get(interaction.guild.roles, name=HELP_EDITOR_ROLE_NAME) + if help_editor_role and help_editor_role in interaction.user.roles: + return True + + return False class ChartCommands(commands.Cog): @@ -24,34 +94,6 @@ class ChartCommands(commands.Cog): self.logger = get_contextual_logger(f'{__name__}.ChartCommands') self.chart_service = get_chart_service() - async def chart_autocomplete( - self, - interaction: discord.Interaction, - current: str - ) -> List[app_commands.Choice[str]]: - """Autocomplete for chart names.""" - chart_keys = self.chart_service.get_chart_keys() - - # Filter based on current input - filtered = [ - key for key in chart_keys - if current.lower() in key.lower() - ][:25] # Discord limit - - # Get chart objects for display names - choices = [] - for key in filtered: - chart = self.chart_service.get_chart(key) - if chart: - choices.append( - app_commands.Choice( - name=f"{chart.name} ({chart.category})", - value=key - ) - ) - - return choices - @app_commands.command( name="charts", description="Display a gameplay chart or infographic" @@ -112,115 +154,11 @@ class ChartCommands(commands.Cog): followup_embed.set_image(url=url) await interaction.followup.send(embed=followup_embed) - -class ChartAdminCommands(commands.Cog): - """Chart management command handlers for administrators.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.ChartAdminCommands') - self.chart_service = get_chart_service() - - @app_commands.command( - name="chart-add", - description="[Admin] Add a new chart to the library" - ) - @app_commands.describe( - key="Unique identifier for the chart (e.g., 'rest', 'sac-bunt')", - name="Display name for the chart", - category="Category (gameplay, defense, reference, stats)", - url="Image URL for the chart", - description="Optional description of the chart" - ) - @app_commands.checks.has_permissions(administrator=True) - @logged_command("/chart-add") - async def chart_add( - self, - interaction: discord.Interaction, - key: str, - name: str, - category: str, - url: str, - description: Optional[str] = None - ): - """Add a new chart to the library.""" - set_discord_context( - interaction=interaction, - command="/chart-add", - chart_key=key, - chart_name=name - ) - - # Validate category - valid_categories = list(self.chart_service.get_categories().keys()) - if category not in valid_categories: - raise BotException( - f"Invalid category. Must be one of: {', '.join(valid_categories)}" - ) - - # Add chart (service will handle duplicate key check) - self.chart_service.add_chart( - key=key, - name=name, - category=category, - urls=[url], - description=description or "" - ) - - # Success response - embed = EmbedTemplate.success( - title="Chart Added", - description=f"Successfully added chart '{name}'" - ) - embed.add_field(name="Key", value=key, inline=True) - embed.add_field(name="Category", value=category, inline=True) - embed.set_image(url=url) - - await interaction.response.send_message(embed=embed, ephemeral=True) - - @app_commands.command( - name="chart-remove", - description="[Admin] Remove a chart from the library" - ) - @app_commands.describe(key="Chart key to remove") - @app_commands.checks.has_permissions(administrator=True) - @logged_command("/chart-remove") - async def chart_remove( - self, - interaction: discord.Interaction, - key: str - ): - """Remove a chart from the library.""" - set_discord_context( - interaction=interaction, - command="/chart-remove", - chart_key=key - ) - - # Get chart before removing (for confirmation message) - chart = self.chart_service.get_chart(key) - if chart is None: - raise BotException(f"Chart '{key}' not found") - - # Remove chart - self.chart_service.remove_chart(key) - - # Success response - embed = EmbedTemplate.success( - title="Chart Removed", - description=f"Successfully removed chart '{chart.name}'" - ) - embed.add_field(name="Key", value=key, inline=True) - embed.add_field(name="Category", value=chart.category, inline=True) - - await interaction.response.send_message(embed=embed, ephemeral=True) - @app_commands.command( name="chart-list", - description="[Admin] List all available charts" + description="List all available charts" ) @app_commands.describe(category="Filter by category (optional)") - @app_commands.checks.has_permissions(administrator=True) @logged_command("/chart-list") async def chart_list( self, @@ -276,11 +214,135 @@ class ChartAdminCommands(commands.Cog): inline=False ) + await interaction.response.send_message(embed=embed) + + +class ChartManageGroup(app_commands.Group): + """Chart management commands for administrators and help editors.""" + + def __init__(self): + super().__init__( + name="chart-manage", + description="Manage charts (admin/help editor only)" + ) + self.logger = get_contextual_logger(f'{__name__}.ChartManageGroup') + self.chart_service = get_chart_service() + + @app_commands.command( + name="add", + description="Add a new chart to the library" + ) + @app_commands.describe( + key="Unique identifier for the chart (e.g., 'rest', 'sac-bunt')", + name="Display name for the chart", + category="Category key (use autocomplete)", + url="Image URL for the chart", + description="Optional description of the chart" + ) + @app_commands.autocomplete(category=category_autocomplete) + @logged_command("/chart-manage add") + async def add( + self, + interaction: discord.Interaction, + key: str, + name: str, + category: str, + url: str, + description: Optional[str] = None + ): + """Add a new chart to the library.""" + # Check permissions + if not has_manage_permission(interaction): + embed = EmbedTemplate.error( + title="Permission Denied", + description=f"Only administrators and users with the **{HELP_EDITOR_ROLE_NAME}** role can manage charts." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + set_discord_context( + interaction=interaction, + command="/chart-manage add", + chart_key=key, + chart_name=name + ) + + # Validate category + valid_categories = list(self.chart_service.get_categories().keys()) + if category not in valid_categories: + raise BotException( + f"Invalid category. Must be one of: {', '.join(valid_categories)}" + ) + + # Add chart (service will handle duplicate key check) + self.chart_service.add_chart( + key=key, + name=name, + category=category, + urls=[url], + description=description or "" + ) + + # Success response + embed = EmbedTemplate.success( + title="Chart Added", + description=f"Successfully added chart '{name}'" + ) + embed.add_field(name="Key", value=key, inline=True) + embed.add_field(name="Category", value=category, inline=True) + embed.set_image(url=url) + await interaction.response.send_message(embed=embed, ephemeral=True) @app_commands.command( - name="chart-update", - description="[Admin] Update a chart's properties" + name="remove", + description="Remove a chart from the library" + ) + @app_commands.describe(key="Chart key to remove") + @app_commands.autocomplete(key=chart_autocomplete) + @logged_command("/chart-manage remove") + async def remove( + self, + interaction: discord.Interaction, + key: str + ): + """Remove a chart from the library.""" + # Check permissions + if not has_manage_permission(interaction): + embed = EmbedTemplate.error( + title="Permission Denied", + description=f"Only administrators and users with the **{HELP_EDITOR_ROLE_NAME}** role can manage charts." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + set_discord_context( + interaction=interaction, + command="/chart-manage remove", + chart_key=key + ) + + # Get chart before removing (for confirmation message) + chart = self.chart_service.get_chart(key) + if chart is None: + raise BotException(f"Chart '{key}' not found") + + # Remove chart + self.chart_service.remove_chart(key) + + # Success response + embed = EmbedTemplate.success( + title="Chart Removed", + description=f"Successfully removed chart '{chart.name}'" + ) + embed.add_field(name="Key", value=key, inline=True) + embed.add_field(name="Category", value=chart.category, inline=True) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command( + name="update", + description="Update a chart's properties" ) @app_commands.describe( key="Chart key to update", @@ -289,9 +351,12 @@ class ChartAdminCommands(commands.Cog): url="New image URL (optional)", description="New description (optional)" ) - @app_commands.checks.has_permissions(administrator=True) - @logged_command("/chart-update") - async def chart_update( + @app_commands.autocomplete( + key=chart_autocomplete, + category=category_autocomplete + ) + @logged_command("/chart-manage update") + async def update( self, interaction: discord.Interaction, key: str, @@ -301,9 +366,18 @@ class ChartAdminCommands(commands.Cog): description: Optional[str] = None ): """Update a chart's properties.""" + # Check permissions + if not has_manage_permission(interaction): + embed = EmbedTemplate.error( + title="Permission Denied", + description=f"Only administrators and users with the **{HELP_EDITOR_ROLE_NAME}** role can manage charts." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + set_discord_context( interaction=interaction, - command="/chart-update", + command="/chart-manage update", chart_key=key ) @@ -347,7 +421,219 @@ class ChartAdminCommands(commands.Cog): await interaction.response.send_message(embed=embed, ephemeral=True) +class ChartCategoryGroup(app_commands.Group): + """Chart category management commands for administrators and help editors.""" + + def __init__(self): + super().__init__( + name="chart-categories", + description="Manage chart categories (admin/help editor only)" + ) + self.logger = get_contextual_logger(f'{__name__}.ChartCategoryGroup') + self.chart_service = get_chart_service() + + @app_commands.command( + name="list", + description="List all chart categories" + ) + @logged_command("/chart-categories list") + async def list_categories( + self, + interaction: discord.Interaction + ): + """List all chart categories.""" + # Check permissions + if not has_manage_permission(interaction): + embed = EmbedTemplate.error( + title="Permission Denied", + description=f"Only administrators and users with the **{HELP_EDITOR_ROLE_NAME}** role can manage categories." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + set_discord_context( + interaction=interaction, + command="/chart-categories list" + ) + + categories = self.chart_service.get_categories() + + if not categories: + embed = EmbedTemplate.info( + title="📊 Chart Categories", + description="No categories defined. Use `/chart-categories add` to create one." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + # Create embed + embed = EmbedTemplate.create_base_embed( + title="📊 Chart Categories", + description=f"Total: {len(categories)} category(ies)", + color=EmbedColors.PRIMARY + ) + + # List all categories + category_list = "\n".join([ + f"• `{key}` - {display_name}" + for key, display_name in sorted(categories.items()) + ]) + + embed.add_field( + name="Categories", + value=category_list, + inline=False + ) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command( + name="add", + description="Add a new chart category" + ) + @app_commands.describe( + key="Category key (e.g., 'gameplay', 'stats')", + display_name="Display name (e.g., 'Gameplay Charts', 'Statistics')" + ) + @logged_command("/chart-categories add") + async def add_category( + self, + interaction: discord.Interaction, + key: str, + display_name: str + ): + """Add a new chart category.""" + # Check permissions + if not has_manage_permission(interaction): + embed = EmbedTemplate.error( + title="Permission Denied", + description=f"Only administrators and users with the **{HELP_EDITOR_ROLE_NAME}** role can manage categories." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + set_discord_context( + interaction=interaction, + command="/chart-categories add", + category_key=key + ) + + # Add category (service will handle duplicate check) + self.chart_service.add_category(key=key, display_name=display_name) + + # Success response + embed = EmbedTemplate.success( + title="Category Added", + description=f"Successfully added category '{display_name}'" + ) + embed.add_field(name="Key", value=key, inline=True) + embed.add_field(name="Display Name", value=display_name, inline=True) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command( + name="remove", + description="Remove a chart category" + ) + @app_commands.describe(key="Category key to remove") + @app_commands.autocomplete(key=category_autocomplete) + @logged_command("/chart-categories remove") + async def remove_category( + self, + interaction: discord.Interaction, + key: str + ): + """Remove a chart category.""" + # Check permissions + if not has_manage_permission(interaction): + embed = EmbedTemplate.error( + title="Permission Denied", + description=f"Only administrators and users with the **{HELP_EDITOR_ROLE_NAME}** role can manage categories." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + set_discord_context( + interaction=interaction, + command="/chart-categories remove", + category_key=key + ) + + # Get category before removing (for confirmation message) + categories = self.chart_service.get_categories() + if key not in categories: + raise BotException(f"Category '{key}' not found") + + category_display = categories[key] + + # Remove category (service will validate no charts use it) + self.chart_service.remove_category(key) + + # Success response + embed = EmbedTemplate.success( + title="Category Removed", + description=f"Successfully removed category '{category_display}'" + ) + embed.add_field(name="Key", value=key, inline=True) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command( + name="rename", + description="Rename a chart category" + ) + @app_commands.describe( + key="Category key to rename", + new_display_name="New display name" + ) + @app_commands.autocomplete(key=category_autocomplete) + @logged_command("/chart-categories rename") + async def rename_category( + self, + interaction: discord.Interaction, + key: str, + new_display_name: str + ): + """Rename a chart category.""" + # Check permissions + if not has_manage_permission(interaction): + embed = EmbedTemplate.error( + title="Permission Denied", + description=f"Only administrators and users with the **{HELP_EDITOR_ROLE_NAME}** role can manage categories." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + set_discord_context( + interaction=interaction, + command="/chart-categories rename", + category_key=key + ) + + # Get old name for confirmation + categories = self.chart_service.get_categories() + if key not in categories: + raise BotException(f"Category '{key}' not found") + + old_display_name = categories[key] + + # Update category + self.chart_service.update_category(key=key, display_name=new_display_name) + + # Success response + embed = EmbedTemplate.success( + title="Category Renamed", + description=f"Successfully renamed category from '{old_display_name}' to '{new_display_name}'" + ) + embed.add_field(name="Key", value=key, inline=True) + embed.add_field(name="Old Name", value=old_display_name, inline=True) + embed.add_field(name="New Name", value=new_display_name, inline=True) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + async def setup(bot: commands.Bot): """Setup function for chart commands.""" await bot.add_cog(ChartCommands(bot)) - await bot.add_cog(ChartAdminCommands(bot)) + bot.tree.add_command(ChartManageGroup()) + bot.tree.add_command(ChartCategoryGroup()) diff --git a/commands/voice/README.md b/commands/voice/README.md index 94b0ad5..db7d712 100644 --- a/commands/voice/README.md +++ b/commands/voice/README.md @@ -146,7 +146,7 @@ if hasattr(self.bot, 'voice_cleanup_service'): ### Cleanup Service Settings - **`cleanup_interval`**: How often to check channels (default: 60 seconds) - **`empty_threshold`**: Minutes empty before deletion (default: 5 minutes) -- **`data_file`**: JSON persistence file path (default: "storage/voice_channels.json") +- **`data_file`**: JSON persistence file path (default: "data/voice_channels.json") ### Channel Categories - Channels are created in the "Voice Channels" category if it exists diff --git a/commands/voice/cleanup_service.py b/commands/voice/cleanup_service.py index f4a314b..737cb26 100644 --- a/commands/voice/cleanup_service.py +++ b/commands/voice/cleanup_service.py @@ -25,7 +25,7 @@ class VoiceChannelCleanupService: - Stale entry removal and recovery """ - def __init__(self, data_file: str = "storage/voice_channels.json"): + def __init__(self, data_file: str = "data/voice_channels.json"): """ Initialize the cleanup service. diff --git a/commands/voice/tracker.py b/commands/voice/tracker.py index fb54145..5ca4359 100644 --- a/commands/voice/tracker.py +++ b/commands/voice/tracker.py @@ -25,7 +25,7 @@ class VoiceChannelTracker: - Automatic stale entry removal """ - def __init__(self, data_file: str = "storage/voice_channels.json"): + def __init__(self, data_file: str = "data/voice_channels.json"): """ Initialize the voice channel tracker. diff --git a/models/team.py b/models/team.py index 0dd6184..1a952f8 100644 --- a/models/team.py +++ b/models/team.py @@ -9,6 +9,7 @@ from pydantic import Field from models.base import SBABaseModel from models.division import Division +from models.manager import Manager class RosterType(Enum): @@ -34,7 +35,9 @@ class Team(SBABaseModel): gmid: Optional[int] = Field(None, description="Primary general manager ID") gmid2: Optional[int] = Field(None, description="Secondary general manager ID") manager1_id: Optional[int] = Field(None, description="Primary manager ID") + manager1: Optional[Manager] = Field(None, description="Manager object") manager2_id: Optional[int] = Field(None, description="Secondary manager ID") + manager2: Optional[Manager] = Field(None, description="Manager object") # Team metadata division_id: Optional[int] = Field(None, description="Division ID") @@ -196,5 +199,18 @@ class Team(SBABaseModel): """ return self._get_base_abbrev() == other_team._get_base_abbrev() + def gm_names(self) -> str: + if any([self.manager1, self.manager2]): + names = '' + if self.manager1: + names += f'{self.manager1}' + if self.manager2: + names += f', {self.manager2}' + return names + if any([self.manager1_id, self.manager2_id]): + mgr_count = sum(1 for x in [self.manager1_id, self.manager2_id] if x is not None) + return f'{mgr_count} GM{"s" if mgr_count > 1 else ""}' + return 'Unknown' + def __str__(self): return f"{self.abbrev} - {self.lname}" \ No newline at end of file diff --git a/services/chart_service.py b/services/chart_service.py index ebd4a3c..e3f4855 100644 --- a/services/chart_service.py +++ b/services/chart_service.py @@ -37,7 +37,7 @@ class Chart: class ChartService: """Service for managing gameplay charts and infographics.""" - CHARTS_FILE = Path(__file__).parent.parent / 'storage' / 'charts.json' + CHARTS_FILE = Path(__file__).parent.parent / 'data' / 'charts.json' def __init__(self): """Initialize the chart service.""" @@ -235,6 +235,68 @@ class ChartService: self._save_charts() logger.info(f"Removed chart: {key}") + def add_category(self, key: str, display_name: str) -> None: + """ + Add a new category. + + Args: + key: Unique identifier for the category (e.g., 'gameplay') + display_name: Display name for the category (e.g., 'Gameplay Charts') + + Raises: + BotException: If category key already exists + """ + if key in self._categories: + raise BotException(f"Category '{key}' already exists") + + self._categories[key] = display_name + self._save_charts() + logger.info(f"Added category: {key} - {display_name}") + + def remove_category(self, key: str) -> None: + """ + Remove a category. + + Args: + key: Category key to remove + + Raises: + BotException: If category doesn't exist or charts are using it + """ + if key not in self._categories: + raise BotException(f"Category '{key}' not found") + + # Check if any charts use this category + charts_using = [c for c in self._charts.values() if c.category == key] + if charts_using: + chart_names = ", ".join([c.name for c in charts_using]) + raise BotException( + f"Cannot remove category '{key}' - used by {len(charts_using)} chart(s): {chart_names}" + ) + + del self._categories[key] + self._save_charts() + logger.info(f"Removed category: {key}") + + def update_category(self, key: str, display_name: str) -> None: + """ + Update category display name. + + Args: + key: Category key to update + display_name: New display name + + Raises: + BotException: If category doesn't exist + """ + if key not in self._categories: + raise BotException(f"Category '{key}' not found") + + old_name = self._categories[key] + self._categories[key] = display_name + self._save_charts() + logger.info(f"Updated category: {key} from '{old_name}' to '{display_name}'") + def reload_charts(self) -> None: """Reload charts from the JSON file.""" self._load_charts() diff --git a/tests/test_commands_charts.py b/tests/test_commands_charts.py index a07faa7..bc3c2e8 100644 --- a/tests/test_commands_charts.py +++ b/tests/test_commands_charts.py @@ -8,7 +8,10 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from discord import app_commands -from commands.utilities.charts import ChartCommands, ChartAdminCommands +from commands.utilities.charts import ( + ChartCommands, ChartManageGroup, ChartCategoryGroup, + chart_autocomplete, category_autocomplete +) from services.chart_service import ChartService, Chart, get_chart_service from exceptions import BotException @@ -191,6 +194,48 @@ class TestChartService: with pytest.raises(BotException, match="not found"): chart_service.remove_chart('nonexistent') + def test_add_category(self, chart_service): + """Test adding a new category.""" + chart_service.add_category(key='stats', display_name='Statistics Charts') + + categories = chart_service.get_categories() + assert 'stats' in categories + assert categories['stats'] == 'Statistics Charts' + + def test_add_duplicate_category(self, chart_service): + """Test adding a duplicate category raises exception.""" + with pytest.raises(BotException, match="already exists"): + chart_service.add_category(key='gameplay', display_name='Duplicate') + + def test_remove_category(self, chart_service): + """Test removing an unused category.""" + chart_service.remove_category('reference') + + categories = chart_service.get_categories() + assert 'reference' not in categories + + def test_remove_nonexistent_category(self, chart_service): + """Test removing a non-existent category raises exception.""" + with pytest.raises(BotException, match="not found"): + chart_service.remove_category('nonexistent') + + def test_remove_category_with_charts(self, chart_service): + """Test removing a category that charts are using raises exception.""" + with pytest.raises(BotException, match="Cannot remove category"): + chart_service.remove_category('gameplay') + + def test_update_category(self, chart_service): + """Test updating a category display name.""" + chart_service.update_category(key='gameplay', display_name='Updated Gameplay') + + categories = chart_service.get_categories() + assert categories['gameplay'] == 'Updated Gameplay' + + def test_update_nonexistent_category(self, chart_service): + """Test updating a non-existent category raises exception.""" + with pytest.raises(BotException, match="not found"): + chart_service.update_category(key='nonexistent', display_name='New Name') + class TestChartCommands: """Tests for ChartCommands class.""" @@ -244,40 +289,47 @@ class TestChartCommands: await chart_cog.charts.callback(chart_cog, mock_interaction, 'nonexistent') @pytest.mark.asyncio - async def test_chart_autocomplete(self, chart_cog, mock_interaction): + async def test_chart_autocomplete(self, chart_service, mock_interaction): """Test chart autocomplete functionality.""" - # Test with empty current input - choices = await chart_cog.chart_autocomplete(mock_interaction, '') - assert len(choices) == 3 + # Patch get_chart_service to return our test service + with patch('commands.utilities.charts.get_chart_service', return_value=chart_service): + # Test with empty current input + choices = await chart_autocomplete(mock_interaction, '') + assert len(choices) == 3 - # Test with partial match - choices = await chart_cog.chart_autocomplete(mock_interaction, 'def') - assert len(choices) == 1 - assert choices[0].value == 'defense' + # Test with partial match + choices = await chart_autocomplete(mock_interaction, 'def') + assert len(choices) == 1 + assert choices[0].value == 'defense' - # Test with no match - choices = await chart_cog.chart_autocomplete(mock_interaction, 'xyz') - assert len(choices) == 0 + # Test with no match + choices = await chart_autocomplete(mock_interaction, 'xyz') + assert len(choices) == 0 -class TestChartAdminCommands: - """Tests for ChartAdminCommands class.""" +class TestChartManageGroup: + """Tests for ChartManageGroup command group.""" @pytest.fixture - def admin_cog(self, chart_service): - """Create ChartAdminCommands cog with mocked service.""" - bot = AsyncMock() - cog = ChartAdminCommands(bot) + def manage_group(self, chart_service): + """Create ChartManageGroup with mocked service.""" + group = ChartManageGroup() - with patch.object(cog, 'chart_service', chart_service): - yield cog + with patch.object(group, 'chart_service', chart_service): + yield group + + @pytest.fixture + def mock_admin_interaction(self, mock_interaction): + """Create a mock interaction with admin permissions.""" + mock_interaction.user.guild_permissions.administrator = True + return mock_interaction @pytest.mark.asyncio - async def test_chart_add_command(self, admin_cog, mock_interaction): + async def test_chart_add_command(self, manage_group, mock_admin_interaction): """Test adding a new chart via command.""" - await admin_cog.chart_add.callback( - admin_cog, - mock_interaction, + await manage_group.add.callback( + manage_group, + mock_admin_interaction, key='new-chart', name='New Chart', category='gameplay', @@ -286,25 +338,25 @@ class TestChartAdminCommands: ) # Verify success response - mock_interaction.response.send_message.assert_called_once() - call_kwargs = mock_interaction.response.send_message.call_args[1] + mock_admin_interaction.response.send_message.assert_called_once() + call_kwargs = mock_admin_interaction.response.send_message.call_args[1] embed = call_kwargs['embed'] assert '✅ Chart Added' in embed.title assert call_kwargs['ephemeral'] is True # Verify chart was added - chart = admin_cog.chart_service.get_chart('new-chart') + chart = manage_group.chart_service.get_chart('new-chart') assert chart is not None assert chart.name == 'New Chart' @pytest.mark.asyncio - async def test_chart_add_invalid_category(self, admin_cog, mock_interaction): + async def test_chart_add_invalid_category(self, manage_group, mock_admin_interaction): """Test adding a chart with invalid category.""" with pytest.raises(BotException, match="Invalid category"): - await admin_cog.chart_add.callback( - admin_cog, - mock_interaction, + await manage_group.add.callback( + manage_group, + mock_admin_interaction, key='new-chart', name='New Chart', category='invalid-category', @@ -313,60 +365,34 @@ class TestChartAdminCommands: ) @pytest.mark.asyncio - async def test_chart_remove_command(self, admin_cog, mock_interaction): + async def test_chart_remove_command(self, manage_group, mock_admin_interaction): """Test removing a chart via command.""" - await admin_cog.chart_remove.callback(admin_cog, mock_interaction, 'rest') + await manage_group.remove.callback(manage_group, mock_admin_interaction, 'rest') # Verify success response - mock_interaction.response.send_message.assert_called_once() - call_kwargs = mock_interaction.response.send_message.call_args[1] + mock_admin_interaction.response.send_message.assert_called_once() + call_kwargs = mock_admin_interaction.response.send_message.call_args[1] embed = call_kwargs['embed'] assert '✅ Chart Removed' in embed.title assert call_kwargs['ephemeral'] is True # Verify chart was removed - chart = admin_cog.chart_service.get_chart('rest') + chart = manage_group.chart_service.get_chart('rest') assert chart is None @pytest.mark.asyncio - async def test_chart_remove_not_found(self, admin_cog, mock_interaction): + async def test_chart_remove_not_found(self, manage_group, mock_admin_interaction): """Test removing a non-existent chart.""" with pytest.raises(BotException, match="not found"): - await admin_cog.chart_remove.callback(admin_cog, mock_interaction, 'nonexistent') + await manage_group.remove.callback(manage_group, mock_admin_interaction, 'nonexistent') @pytest.mark.asyncio - async def test_chart_list_all(self, admin_cog, mock_interaction): - """Test listing all charts.""" - await admin_cog.chart_list.callback(admin_cog, mock_interaction, category=None) - - # Verify response - mock_interaction.response.send_message.assert_called_once() - call_kwargs = mock_interaction.response.send_message.call_args[1] - embed = call_kwargs['embed'] - - assert '📊 All Available Charts' in embed.title - assert 'Total: 3 chart(s)' in embed.description - - @pytest.mark.asyncio - async def test_chart_list_by_category(self, admin_cog, mock_interaction): - """Test listing charts by category.""" - await admin_cog.chart_list.callback(admin_cog, mock_interaction, category='gameplay') - - # Verify response - mock_interaction.response.send_message.assert_called_once() - call_kwargs = mock_interaction.response.send_message.call_args[1] - embed = call_kwargs['embed'] - - assert "Charts in 'gameplay'" in embed.title - assert 'Total: 2 chart(s)' in embed.description - - @pytest.mark.asyncio - async def test_chart_update_command(self, admin_cog, mock_interaction): + async def test_chart_update_command(self, manage_group, mock_admin_interaction): """Test updating a chart via command.""" - await admin_cog.chart_update.callback( - admin_cog, - mock_interaction, + await manage_group.update.callback( + manage_group, + mock_admin_interaction, key='rest', name='Updated Rest Chart', category=None, @@ -375,24 +401,24 @@ class TestChartAdminCommands: ) # Verify success response - mock_interaction.response.send_message.assert_called_once() - call_kwargs = mock_interaction.response.send_message.call_args[1] + mock_admin_interaction.response.send_message.assert_called_once() + call_kwargs = mock_admin_interaction.response.send_message.call_args[1] embed = call_kwargs['embed'] assert '✅ Chart Updated' in embed.title # Verify chart was updated - chart = admin_cog.chart_service.get_chart('rest') + chart = manage_group.chart_service.get_chart('rest') assert chart.name == 'Updated Rest Chart' assert chart.description == 'Updated description' @pytest.mark.asyncio - async def test_chart_update_no_fields(self, admin_cog, mock_interaction): + async def test_chart_update_no_fields(self, manage_group, mock_admin_interaction): """Test updating with no fields raises exception.""" with pytest.raises(BotException, match="Must provide at least one field"): - await admin_cog.chart_update.callback( - admin_cog, - mock_interaction, + await manage_group.update.callback( + manage_group, + mock_admin_interaction, key='rest', name=None, category=None, @@ -401,15 +427,114 @@ class TestChartAdminCommands: ) @pytest.mark.asyncio - async def test_chart_update_invalid_category(self, admin_cog, mock_interaction): + async def test_chart_update_invalid_category(self, manage_group, mock_admin_interaction): """Test updating with invalid category.""" with pytest.raises(BotException, match="Invalid category"): - await admin_cog.chart_update.callback( - admin_cog, - mock_interaction, + await manage_group.update.callback( + manage_group, + mock_admin_interaction, key='rest', name=None, category='invalid-category', url=None, description=None ) + + +class TestChartCategoryGroup: + """Tests for ChartCategoryGroup command group.""" + + @pytest.fixture + def category_group(self, chart_service): + """Create ChartCategoryGroup with mocked service.""" + group = ChartCategoryGroup() + + with patch.object(group, 'chart_service', chart_service): + yield group + + @pytest.fixture + def mock_admin_interaction(self, mock_interaction): + """Create a mock interaction with admin permissions.""" + mock_interaction.user.guild_permissions.administrator = True + return mock_interaction + + @pytest.mark.asyncio + async def test_list_categories(self, category_group, mock_admin_interaction): + """Test listing all categories.""" + await category_group.list_categories.callback( + category_group, + mock_admin_interaction + ) + + # Verify response + mock_admin_interaction.response.send_message.assert_called_once() + call_kwargs = mock_admin_interaction.response.send_message.call_args[1] + embed = call_kwargs['embed'] + + assert '📊 Chart Categories' in embed.title + assert call_kwargs['ephemeral'] is True + + @pytest.mark.asyncio + async def test_add_category(self, category_group, mock_admin_interaction): + """Test adding a new category.""" + await category_group.add_category.callback( + category_group, + mock_admin_interaction, + key='stats', + display_name='Statistics Charts' + ) + + # Verify success response + mock_admin_interaction.response.send_message.assert_called_once() + call_kwargs = mock_admin_interaction.response.send_message.call_args[1] + embed = call_kwargs['embed'] + + assert '✅ Category Added' in embed.title + assert call_kwargs['ephemeral'] is True + + # Verify category was added + categories = category_group.chart_service.get_categories() + assert 'stats' in categories + + @pytest.mark.asyncio + async def test_remove_category(self, category_group, mock_admin_interaction): + """Test removing a category.""" + await category_group.remove_category.callback( + category_group, + mock_admin_interaction, + key='reference' + ) + + # Verify success response + mock_admin_interaction.response.send_message.assert_called_once() + call_kwargs = mock_admin_interaction.response.send_message.call_args[1] + embed = call_kwargs['embed'] + + assert '✅ Category Removed' in embed.title + assert call_kwargs['ephemeral'] is True + + # Verify category was removed + categories = category_group.chart_service.get_categories() + assert 'reference' not in categories + + @pytest.mark.asyncio + async def test_rename_category(self, category_group, mock_admin_interaction): + """Test renaming a category.""" + await category_group.rename_category.callback( + category_group, + mock_admin_interaction, + key='gameplay', + new_display_name='Updated Gameplay' + ) + + # Verify success response + mock_admin_interaction.response.send_message.assert_called_once() + call_kwargs = mock_admin_interaction.response.send_message.call_args[1] + embed = call_kwargs['embed'] + + assert '✅ Category Renamed' in embed.title + assert call_kwargs['ephemeral'] is True + + # Verify category was renamed + categories = category_group.chart_service.get_categories() + assert categories['gameplay'] == 'Updated Gameplay'