CLAUDE: Reorganize data storage and enhance team/roster displays

Standardize data file locations to data/ directory and improve command organization with better UI for team rosters, pagination for team lists, and refactored chart commands into logical command groups.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-15 19:05:51 -05:00
parent 3c66ada99d
commit 7aa454f619
18 changed files with 1296 additions and 397 deletions

386
COMMAND_LIST.md Normal file
View File

@ -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 <name>`
Display player information and statistics
- **Parameters:** Player name (autocomplete enabled)
---
## 🏟️ Team Commands
### `/team <abbrev> [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 <abbrev> [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 <other_team>`
Start a new trade with another team
- **Parameters:**
- `other_team`: Team abbreviation (autocomplete enabled)
- **Creates:** Dedicated trade discussion channel
### `/trade add-team <other_team>`
Add another team to your current trade (for 3+ team trades)
- **Parameters:**
- `other_team`: Team abbreviation (autocomplete enabled)
### `/trade add-player <player_name> <destination_team>`
Add a player to the trade
- **Parameters:**
- `player_name`: Player name (autocomplete enabled)
- `destination_team`: Team abbreviation (autocomplete enabled)
### `/trade supplementary <player_name> <destination>`
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 <dice>`
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 <card_type>`
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 <position>`
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 <chart_name>`
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 <name>`
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 <name>`
Edit one of your custom commands
- **Parameters:**
- `name`: Name of command to edit
### `/cc-delete <name>`
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 <name>`
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 <topic>`
Edit an existing help topic (admin/help editor only)
- **Parameters:**
- `topic`: Topic name to edit (autocomplete enabled)
### `/help-delete <topic>`
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 <image_type> <player_name> <image_url>`
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 <cog>`
Reload a specific bot cog
- **Parameters:**
- `cog`: Name of cog to reload
### `/admin-sync`
Sync application commands with Discord
### `/admin-clear <amount>`
Clear messages from the current channel
- **Parameters:**
- `amount`: Number of messages to clear
### `/admin-announce <message>`
Send an announcement to the current channel
- **Parameters:**
- `message`: Announcement text
### `/admin-maintenance`
Toggle maintenance mode for the bot
### `/admin-timeout <user> <duration> [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 <user>`
Remove timeout from a user
- **Parameters:**
- `user`: User to remove timeout from
### `/admin-kick <user> [reason]`
Kick a user from the server
- **Parameters:**
- `user`: User to kick
- `reason`: Optional reason
### `/admin-ban <user> [reason]`
Ban a user from the server
- **Parameters:**
- `user`: User to ban
- `reason`: Optional reason
### `/admin-unban <user_id>`
Unban a user from the server
- **Parameters:**
- `user_id`: Discord user ID to unban
### `/admin-userinfo <user>`
Display detailed information about a user
- **Parameters:**
- `user`: User to get info about
---
## 🛠️ Admin - Chart Management Commands
### `/chart-add <key> <name> <category> <url> [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 <key>`
[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 <key> [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

View File

@ -56,7 +56,7 @@ This document outlines the remaining functionality required before the Discord B
- Admin commands for chart management (add, remove, list, update) - Admin commands for chart management (add, remove, list, update)
- Category organization (gameplay, defense, reference, stats) - Category organization (gameplay, defense, reference, stats)
- Proper embed formatting with descriptions - Proper embed formatting with descriptions
- **Data Storage**: `storage/charts.json` with JSON persistence - **Data Storage**: `data/charts.json` with JSON persistence
- **Completed**: January 2025 - **Completed**: January 2025
#### 4. Custom Help System **✅ COMPLETED** #### 4. Custom Help System **✅ COMPLETED**

View File

@ -22,8 +22,8 @@ class LeagueInfoCommands(commands.Cog):
self.logger = get_contextual_logger(f'{__name__}.LeagueInfoCommands') self.logger = get_contextual_logger(f'{__name__}.LeagueInfoCommands')
self.logger.info("LeagueInfoCommands cog initialized") self.logger.info("LeagueInfoCommands cog initialized")
@discord.app_commands.command(name="league", description="Display current league status and information") @discord.app_commands.command(name="league-metadata", description="Display current league metadata")
@logged_command("/league") @logged_command("/league-metadata")
async def league_info(self, interaction: discord.Interaction): async def league_info(self, interaction: discord.Interaction):
"""Display current league state and information.""" """Display current league state and information."""
await interaction.response.defer() await interaction.response.defer()
@ -41,8 +41,8 @@ class LeagueInfoCommands(commands.Cog):
# Create league info embed # Create league info embed
embed = EmbedTemplate.create_base_embed( embed = EmbedTemplate.create_base_embed(
title="🏆 SBA League Status", title="🏆 SBA League Metadata",
description="Current league information and status" description="Current league metadata"
) )
# Basic league info # Basic league info

View File

@ -55,53 +55,53 @@ class ScheduleCommands(commands.Cog):
# Show recent/upcoming games # Show recent/upcoming games
await self._show_current_schedule(interaction, search_season) await self._show_current_schedule(interaction, search_season)
@discord.app_commands.command( # @discord.app_commands.command(
name="results", # name="results",
description="Display recent game results" # description="Display recent game results"
) # )
@discord.app_commands.describe( # @discord.app_commands.describe(
season="Season to show results for (defaults to current season)", # season="Season to show results for (defaults to current season)",
week="Specific week to show results for (optional)" # week="Specific week to show results for (optional)"
) # )
@logged_command("/results") # @logged_command("/results")
async def results( # async def results(
self, # self,
interaction: discord.Interaction, # interaction: discord.Interaction,
season: Optional[int] = None, # season: Optional[int] = None,
week: Optional[int] = None # week: Optional[int] = None
): # ):
"""Display recent game results.""" # """Display recent game results."""
await interaction.response.defer() # await interaction.response.defer()
search_season = season or SBA_CURRENT_SEASON # search_season = season or SBA_CURRENT_SEASON
if week: # if week:
# Show specific week results # # Show specific week results
games = await schedule_service.get_week_schedule(search_season, week) # games = await schedule_service.get_week_schedule(search_season, week)
completed_games = [game for game in games if game.is_completed] # completed_games = [game for game in games if game.is_completed]
if not completed_games: # if not completed_games:
await interaction.followup.send( # await interaction.followup.send(
f"❌ No completed games found for season {search_season}, week {week}.", # f"❌ No completed games found for season {search_season}, week {week}.",
ephemeral=True # ephemeral=True
) # )
return # return
embed = await self._create_week_results_embed(completed_games, search_season, week) # embed = await self._create_week_results_embed(completed_games, search_season, week)
await interaction.followup.send(embed=embed) # await interaction.followup.send(embed=embed)
else: # else:
# Show recent results # # Show recent results
recent_games = await schedule_service.get_recent_games(search_season) # recent_games = await schedule_service.get_recent_games(search_season)
if not recent_games: # if not recent_games:
await interaction.followup.send( # await interaction.followup.send(
f"❌ No recent games found for season {search_season}.", # f"❌ No recent games found for season {search_season}.",
ephemeral=True # ephemeral=True
) # )
return # return
embed = await self._create_recent_results_embed(recent_games, search_season) # embed = await self._create_recent_results_embed(recent_games, search_season)
await interaction.followup.send(embed=embed) # await interaction.followup.send(embed=embed)
async def _show_week_schedule(self, interaction: discord.Interaction, season: int, week: int): async def _show_week_schedule(self, interaction: discord.Interaction, season: int, week: int):
"""Show schedule for a specific week.""" """Show schedule for a specific week."""

View File

@ -172,23 +172,23 @@ class StandingsCommands(commands.Cog):
inline=False inline=False
) )
# Add additional stats for top teams # # Add additional stats for top teams
if len(teams) >= 3: # if len(teams) >= 3:
stats_lines = [] # stats_lines = []
for team in teams[:3]: # Top 3 teams # for team in teams[:3]: # Top 3 teams
stats_line = ( # stats_line = (
f"**{team.team.abbrev}**: " # f"**{team.team.abbrev}**: "
f"Home {team.home_record}" # f"Home {team.home_record} • "
f"Last 8: {team.last8_record}" # f"Last 8: {team.last8_record} • "
f"Streak: {team.current_streak}" # f"Streak: {team.current_streak}"
) # )
stats_lines.append(stats_line) # stats_lines.append(stats_line)
embed.add_field( # embed.add_field(
name="Recent Form (Top 3)", # name="Recent Form (Top 3)",
value="\n".join(stats_lines), # value="\n".join(stats_lines),
inline=False # inline=False
) # )
embed.set_footer(text=f"Season {season}") embed.set_footer(text=f"Season {season}")
return embed return embed

View File

@ -22,7 +22,7 @@ class SoakTracker:
- Time-based calculations for disappointment tiers - 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. Initialize the soak tracker.

View File

@ -8,12 +8,13 @@ import discord
from discord.ext import commands from discord.ext import commands
from services import team_service, player_service 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 constants import SBA_CURRENT_SEASON
from utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
from utils.decorators import logged_command from utils.decorators import logged_command
from exceptions import BotException from exceptions import BotException
from views.embeds import EmbedTemplate, EmbedColors from views.embeds import EmbedTemplate, EmbedColors
from views.base import PaginationView
class TeamInfoCommands(commands.Cog): class TeamInfoCommands(commands.Cog):
@ -78,36 +79,53 @@ class TeamInfoCommands(commands.Cog):
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
# Sort teams by abbreviation # Filter to major league teams only and sort by abbreviation
teams.sort(key=lambda t: t.abbrev) 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]
# Create embed with team list
embed = EmbedTemplate.create_base_embed( embed = EmbedTemplate.create_base_embed(
title=f"SBA Teams - Season {season}", title=f"SBA Teams - Season {season}",
color=EmbedColors.PRIMARY color=EmbedColors.PRIMARY
) )
# Group teams by division if available for team in page_teams:
if any(team.division_id for team in teams): embed.add_field(
divisions = {} name=f'{team}',
for team in teams: value=self._team_detail_description(team),
div_id = team.division_id or 0 inline=False
if div_id not in divisions: )
divisions[div_id] = []
divisions[div_id].append(team)
for div_id, div_teams in sorted(divisions.items()): embed.set_footer(text=f"Total: {len(ml_teams)} teams")
div_name = f"Division {div_id}" if div_id > 0 else "Unassigned" pages.append(embed)
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) # 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: else:
# Simple list if no divisions await interaction.followup.send(embed=pages[0])
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") def _team_detail_description(self, team: Team) -> str:
return f'GM: {team.gm_names()}\nID: {team.id}'
await interaction.followup.send(embed=embed)
async def _create_team_embed(self, team: Team, standings_data: Optional[dict] = None) -> discord.Embed: async def _create_team_embed(self, team: Team, standings_data: Optional[dict] = None) -> discord.Embed:
"""Create a rich embed for team information.""" """Create a rich embed for team information."""

View File

@ -7,6 +7,7 @@ from typing import Optional, Dict, Any, List
import discord import discord
from discord.ext import commands from discord.ext import commands
from models.player import Player
from services import team_service, player_service from services import team_service, player_service
from models.team import Team from models.team import Team
from constants import SBA_CURRENT_SEASON 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.command(name="roster", description="Display team roster")
@discord.app_commands.describe( @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)" roster_type="Roster week: current or next (defaults to current)"
) )
@discord.app_commands.choices(roster_type=[ @discord.app_commands.choices(roster_type=[
@ -77,64 +78,51 @@ class TeamRosterCommands(commands.Cog):
# Main roster embed # Main roster embed
embed = EmbedTemplate.create_base_embed( embed = EmbedTemplate.create_base_embed(
title=f"{team.abbrev} - {roster_type.title()} Roster", title=f"{team.abbrev} - {roster_type.title()} Week",
description=f"{team.lname} roster breakdown", description=f"{team.lname} Roster Breakdown",
color=int(team.color, 16) if team.color else EmbedColors.PRIMARY color=int(team.color, 16) if team.color else EmbedColors.PRIMARY
) )
# Position counts for active roster # Position counts for active roster
if 'active' in roster_data: for key in ['active', 'longil', 'shortil']:
active_roster = roster_data['active'] if key in roster_data:
this_roster = roster_data[key]
# Batting positions players = this_roster.get('players')
batting_positions = ['C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH'] if len(players) > 0:
batting_counts = [] this_team = players[0].get("team", {"id": "Unknown", "sname": "Unknown"})
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( embed.add_field(
name="Batting Positions", name='Team (ID)',
value="\n".join(batting_counts), value=f'{this_team.get("sname")} ({this_team.get("id")})',
inline=True inline=True
) )
embed.add_field( embed.add_field(
name="Pitching Positions", name='Player Count',
value="\n".join(pitching_counts), value=f'{len(players)} Players'
inline=True
) )
# Total WAR # Total WAR
total_war = active_roster.get('WARa', 0) total_war = this_roster.get('WARa', 0)
embed.add_field( embed.add_field(
name="Total sWAR", name="Total sWAR",
value=f"{total_war:.1f}" if isinstance(total_war, (int, float)) else str(total_war), value=f"{total_war:.2f}" if isinstance(total_war, (int, float)) else str(total_war),
inline=True inline=True
) )
# Add injury list summaries embed.add_field(
if 'shortil' in roster_data and roster_data['shortil']: name='Position Counts',
short_il_count = len(roster_data['shortil'].get('players', [])) value=self._position_code_block(this_roster),
embed.add_field(name="Minor League", value=f"{short_il_count} players", inline=True) inline=False
)
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)
embeds.append(embed) embeds.append(embed)
# Create detailed player list embeds if there are players # Create detailed player list embeds if there are players
for roster_name, roster_info in roster_data.items(): for roster_name, roster_info in roster_data.items():
if roster_name in ['active', 'shortil', 'longil'] and 'players' in roster_info: if roster_name in ['active', 'longil', 'shortil'] and 'players' in roster_info:
players = roster_info['players'] players = sorted(roster_info['players'], key=lambda player: player.get('wara', 0), reverse=True)
if players: if players:
player_embed = self._create_player_list_embed( player_embed = self._create_player_list_embed(
team, roster_name, players team, roster_name, players
@ -143,13 +131,20 @@ class TeamRosterCommands(commands.Cog):
return embeds 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, def _create_player_list_embed(self, team: Team, roster_name: str,
players: List[Dict[str, Any]]) -> discord.Embed: players: List[Dict[str, Any]]) -> discord.Embed:
"""Create an embed with detailed player list.""" """Create an embed with detailed player list."""
roster_titles = { roster_titles = {
'active': 'Active Roster', 'active': 'Active Roster',
'shortil': 'Minor League', 'longil': 'Minor League',
'longil': 'Injured List' 'shortil': 'Injured List'
} }
embed = EmbedTemplate.create_base_embed( embed = EmbedTemplate.create_base_embed(
@ -162,33 +157,28 @@ class TeamRosterCommands(commands.Cog):
pitchers = [] pitchers = []
for player in players: for player in players:
name = player.get('name', 'Unknown') try:
positions = player.get('positions', []) this_player = Player.from_api_data(player)
war = player.get('WARa', 0) player_line = f"{this_player} - sWAR: {this_player.wara}"
# Format WAR display if this_player.is_pitcher:
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) pitchers.append(player_line)
else: else:
batters.append(player_line) 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 # Add player lists to embed
if batters: if batters:
# Split long lists into multiple fields if needed # 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): for i, chunk in enumerate(batter_chunks):
field_name = "Batters" if i == 0 else f"Batters (cont.)" 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: if pitchers:
pitcher_chunks = self._chunk_list(pitchers, 10) pitcher_chunks = self._chunk_list(pitchers, 16)
for i, chunk in enumerate(pitcher_chunks): for i, chunk in enumerate(pitcher_chunks):
field_name = "Pitchers" if i == 0 else f"Pitchers (cont.)" field_name = "Pitchers" if i == 0 else f"Pitchers (cont.)"
embed.add_field(name=field_name, value="\n".join(chunk), inline=False) embed.add_field(name=field_name, value="\n".join(chunk), inline=False)

View File

@ -27,7 +27,7 @@ class TradeChannelTracker:
- Automatic stale entry removal - 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. Initialize the trade channel tracker.

View File

@ -163,7 +163,7 @@ Administrators can manage the chart library using these commands:
- **Files**: - **Files**:
- `commands/utilities/charts.py` - Command handlers - `commands/utilities/charts.py` - Command handlers
- `services/chart_service.py` - Chart management service - `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 - **Service**: `ChartService` - Manages chart loading, saving, and retrieval
- **Categories**: gameplay, defense, reference, stats - **Categories**: gameplay, defense, reference, stats
- **Logging**: Uses `@logged_command` decorator for automatic logging - **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 → Shows all gameplay charts
``` ```
**Data Structure** (`storage/charts.json`): **Data Structure** (`data/charts.json`):
```json ```json
{ {
"charts": { "charts": {

View File

@ -7,9 +7,9 @@ import logging
from discord.ext import commands from discord.ext import commands
from .weather import WeatherCommands 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__) logger = logging.getLogger(__name__)
@ -28,10 +28,10 @@ async def setup_utilities(bot: commands.Bot) -> tuple[int, int, list[str]]:
failed = 0 failed = 0
failed_modules = [] failed_modules = []
# Cogs that need bot instance
cog_classes = [ cog_classes = [
WeatherCommands, WeatherCommands,
ChartCommands, ChartCommands,
ChartAdminCommands,
] ]
for cog_class in cog_classes: 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 += 1
failed_modules.append(cog_class.__name__) 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 return successful, failed, failed_modules

View File

@ -14,23 +14,18 @@ from utils.logging import get_contextual_logger, set_discord_context
from services.chart_service import get_chart_service, Chart from services.chart_service import get_chart_service, Chart
from views.embeds import EmbedTemplate, EmbedColors from views.embeds import EmbedTemplate, EmbedColors
from exceptions import BotException from exceptions import BotException
from constants import HELP_EDITOR_ROLE_NAME
class ChartCommands(commands.Cog): # Standalone autocomplete functions
"""Chart display command handlers."""
def __init__(self, bot: commands.Bot): async def chart_autocomplete(
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.ChartCommands')
self.chart_service = get_chart_service()
async def chart_autocomplete(
self,
interaction: discord.Interaction, interaction: discord.Interaction,
current: str current: str
) -> List[app_commands.Choice[str]]: ) -> List[app_commands.Choice[str]]:
"""Autocomplete for chart names.""" """Autocomplete for chart names."""
chart_keys = self.chart_service.get_chart_keys() chart_service = get_chart_service()
chart_keys = chart_service.get_chart_keys()
# Filter based on current input # Filter based on current input
filtered = [ filtered = [
@ -41,7 +36,7 @@ class ChartCommands(commands.Cog):
# Get chart objects for display names # Get chart objects for display names
choices = [] choices = []
for key in filtered: for key in filtered:
chart = self.chart_service.get_chart(key) chart = chart_service.get_chart(key)
if chart: if chart:
choices.append( choices.append(
app_commands.Choice( app_commands.Choice(
@ -52,6 +47,53 @@ class ChartCommands(commands.Cog):
return choices 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):
"""Chart display command handlers."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.ChartCommands')
self.chart_service = get_chart_service()
@app_commands.command( @app_commands.command(
name="charts", name="charts",
description="Display a gameplay chart or infographic" description="Display a gameplay chart or infographic"
@ -112,115 +154,11 @@ class ChartCommands(commands.Cog):
followup_embed.set_image(url=url) followup_embed.set_image(url=url)
await interaction.followup.send(embed=followup_embed) 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( @app_commands.command(
name="chart-list", name="chart-list",
description="[Admin] List all available charts" description="List all available charts"
) )
@app_commands.describe(category="Filter by category (optional)") @app_commands.describe(category="Filter by category (optional)")
@app_commands.checks.has_permissions(administrator=True)
@logged_command("/chart-list") @logged_command("/chart-list")
async def chart_list( async def chart_list(
self, self,
@ -276,11 +214,135 @@ class ChartAdminCommands(commands.Cog):
inline=False 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) await interaction.response.send_message(embed=embed, ephemeral=True)
@app_commands.command( @app_commands.command(
name="chart-update", name="remove",
description="[Admin] Update a chart's properties" 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( @app_commands.describe(
key="Chart key to update", key="Chart key to update",
@ -289,9 +351,12 @@ class ChartAdminCommands(commands.Cog):
url="New image URL (optional)", url="New image URL (optional)",
description="New description (optional)" description="New description (optional)"
) )
@app_commands.checks.has_permissions(administrator=True) @app_commands.autocomplete(
@logged_command("/chart-update") key=chart_autocomplete,
async def chart_update( category=category_autocomplete
)
@logged_command("/chart-manage update")
async def update(
self, self,
interaction: discord.Interaction, interaction: discord.Interaction,
key: str, key: str,
@ -301,9 +366,18 @@ class ChartAdminCommands(commands.Cog):
description: Optional[str] = None description: Optional[str] = None
): ):
"""Update a chart's properties.""" """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( set_discord_context(
interaction=interaction, interaction=interaction,
command="/chart-update", command="/chart-manage update",
chart_key=key chart_key=key
) )
@ -347,7 +421,219 @@ class ChartAdminCommands(commands.Cog):
await interaction.response.send_message(embed=embed, ephemeral=True) 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): async def setup(bot: commands.Bot):
"""Setup function for chart commands.""" """Setup function for chart commands."""
await bot.add_cog(ChartCommands(bot)) await bot.add_cog(ChartCommands(bot))
await bot.add_cog(ChartAdminCommands(bot)) bot.tree.add_command(ChartManageGroup())
bot.tree.add_command(ChartCategoryGroup())

View File

@ -146,7 +146,7 @@ if hasattr(self.bot, 'voice_cleanup_service'):
### Cleanup Service Settings ### Cleanup Service Settings
- **`cleanup_interval`**: How often to check channels (default: 60 seconds) - **`cleanup_interval`**: How often to check channels (default: 60 seconds)
- **`empty_threshold`**: Minutes empty before deletion (default: 5 minutes) - **`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 ### Channel Categories
- Channels are created in the "Voice Channels" category if it exists - Channels are created in the "Voice Channels" category if it exists

View File

@ -25,7 +25,7 @@ class VoiceChannelCleanupService:
- Stale entry removal and recovery - 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. Initialize the cleanup service.

View File

@ -25,7 +25,7 @@ class VoiceChannelTracker:
- Automatic stale entry removal - 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. Initialize the voice channel tracker.

View File

@ -9,6 +9,7 @@ from pydantic import Field
from models.base import SBABaseModel from models.base import SBABaseModel
from models.division import Division from models.division import Division
from models.manager import Manager
class RosterType(Enum): class RosterType(Enum):
@ -34,7 +35,9 @@ class Team(SBABaseModel):
gmid: Optional[int] = Field(None, description="Primary general manager ID") gmid: Optional[int] = Field(None, description="Primary general manager ID")
gmid2: Optional[int] = Field(None, description="Secondary general manager ID") gmid2: Optional[int] = Field(None, description="Secondary general manager ID")
manager1_id: Optional[int] = Field(None, description="Primary 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_id: Optional[int] = Field(None, description="Secondary manager ID")
manager2: Optional[Manager] = Field(None, description="Manager object")
# Team metadata # Team metadata
division_id: Optional[int] = Field(None, description="Division ID") 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() 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): def __str__(self):
return f"{self.abbrev} - {self.lname}" return f"{self.abbrev} - {self.lname}"

View File

@ -37,7 +37,7 @@ class Chart:
class ChartService: class ChartService:
"""Service for managing gameplay charts and infographics.""" """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): def __init__(self):
"""Initialize the chart service.""" """Initialize the chart service."""
@ -235,6 +235,68 @@ class ChartService:
self._save_charts() self._save_charts()
logger.info(f"Removed chart: {key}") 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: def reload_charts(self) -> None:
"""Reload charts from the JSON file.""" """Reload charts from the JSON file."""
self._load_charts() self._load_charts()

View File

@ -8,7 +8,10 @@ from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from discord import app_commands 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 services.chart_service import ChartService, Chart, get_chart_service
from exceptions import BotException from exceptions import BotException
@ -191,6 +194,48 @@ class TestChartService:
with pytest.raises(BotException, match="not found"): with pytest.raises(BotException, match="not found"):
chart_service.remove_chart('nonexistent') 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: class TestChartCommands:
"""Tests for ChartCommands class.""" """Tests for ChartCommands class."""
@ -244,40 +289,47 @@ class TestChartCommands:
await chart_cog.charts.callback(chart_cog, mock_interaction, 'nonexistent') await chart_cog.charts.callback(chart_cog, mock_interaction, 'nonexistent')
@pytest.mark.asyncio @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 chart autocomplete functionality."""
# 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 # Test with empty current input
choices = await chart_cog.chart_autocomplete(mock_interaction, '') choices = await chart_autocomplete(mock_interaction, '')
assert len(choices) == 3 assert len(choices) == 3
# Test with partial match # Test with partial match
choices = await chart_cog.chart_autocomplete(mock_interaction, 'def') choices = await chart_autocomplete(mock_interaction, 'def')
assert len(choices) == 1 assert len(choices) == 1
assert choices[0].value == 'defense' assert choices[0].value == 'defense'
# Test with no match # Test with no match
choices = await chart_cog.chart_autocomplete(mock_interaction, 'xyz') choices = await chart_autocomplete(mock_interaction, 'xyz')
assert len(choices) == 0 assert len(choices) == 0
class TestChartAdminCommands: class TestChartManageGroup:
"""Tests for ChartAdminCommands class.""" """Tests for ChartManageGroup command group."""
@pytest.fixture @pytest.fixture
def admin_cog(self, chart_service): def manage_group(self, chart_service):
"""Create ChartAdminCommands cog with mocked service.""" """Create ChartManageGroup with mocked service."""
bot = AsyncMock() group = ChartManageGroup()
cog = ChartAdminCommands(bot)
with patch.object(cog, 'chart_service', chart_service): with patch.object(group, 'chart_service', chart_service):
yield cog 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 @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.""" """Test adding a new chart via command."""
await admin_cog.chart_add.callback( await manage_group.add.callback(
admin_cog, manage_group,
mock_interaction, mock_admin_interaction,
key='new-chart', key='new-chart',
name='New Chart', name='New Chart',
category='gameplay', category='gameplay',
@ -286,25 +338,25 @@ class TestChartAdminCommands:
) )
# Verify success response # Verify success response
mock_interaction.response.send_message.assert_called_once() mock_admin_interaction.response.send_message.assert_called_once()
call_kwargs = mock_interaction.response.send_message.call_args[1] call_kwargs = mock_admin_interaction.response.send_message.call_args[1]
embed = call_kwargs['embed'] embed = call_kwargs['embed']
assert '✅ Chart Added' in embed.title assert '✅ Chart Added' in embed.title
assert call_kwargs['ephemeral'] is True assert call_kwargs['ephemeral'] is True
# Verify chart was added # 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 is not None
assert chart.name == 'New Chart' assert chart.name == 'New Chart'
@pytest.mark.asyncio @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.""" """Test adding a chart with invalid category."""
with pytest.raises(BotException, match="Invalid category"): with pytest.raises(BotException, match="Invalid category"):
await admin_cog.chart_add.callback( await manage_group.add.callback(
admin_cog, manage_group,
mock_interaction, mock_admin_interaction,
key='new-chart', key='new-chart',
name='New Chart', name='New Chart',
category='invalid-category', category='invalid-category',
@ -313,60 +365,34 @@ class TestChartAdminCommands:
) )
@pytest.mark.asyncio @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.""" """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 # Verify success response
mock_interaction.response.send_message.assert_called_once() mock_admin_interaction.response.send_message.assert_called_once()
call_kwargs = mock_interaction.response.send_message.call_args[1] call_kwargs = mock_admin_interaction.response.send_message.call_args[1]
embed = call_kwargs['embed'] embed = call_kwargs['embed']
assert '✅ Chart Removed' in embed.title assert '✅ Chart Removed' in embed.title
assert call_kwargs['ephemeral'] is True assert call_kwargs['ephemeral'] is True
# Verify chart was removed # Verify chart was removed
chart = admin_cog.chart_service.get_chart('rest') chart = manage_group.chart_service.get_chart('rest')
assert chart is None assert chart is None
@pytest.mark.asyncio @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.""" """Test removing a non-existent chart."""
with pytest.raises(BotException, match="not found"): 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 @pytest.mark.asyncio
async def test_chart_list_all(self, admin_cog, mock_interaction): async def test_chart_update_command(self, manage_group, mock_admin_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):
"""Test updating a chart via command.""" """Test updating a chart via command."""
await admin_cog.chart_update.callback( await manage_group.update.callback(
admin_cog, manage_group,
mock_interaction, mock_admin_interaction,
key='rest', key='rest',
name='Updated Rest Chart', name='Updated Rest Chart',
category=None, category=None,
@ -375,24 +401,24 @@ class TestChartAdminCommands:
) )
# Verify success response # Verify success response
mock_interaction.response.send_message.assert_called_once() mock_admin_interaction.response.send_message.assert_called_once()
call_kwargs = mock_interaction.response.send_message.call_args[1] call_kwargs = mock_admin_interaction.response.send_message.call_args[1]
embed = call_kwargs['embed'] embed = call_kwargs['embed']
assert '✅ Chart Updated' in embed.title assert '✅ Chart Updated' in embed.title
# Verify chart was updated # 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.name == 'Updated Rest Chart'
assert chart.description == 'Updated description' assert chart.description == 'Updated description'
@pytest.mark.asyncio @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.""" """Test updating with no fields raises exception."""
with pytest.raises(BotException, match="Must provide at least one field"): with pytest.raises(BotException, match="Must provide at least one field"):
await admin_cog.chart_update.callback( await manage_group.update.callback(
admin_cog, manage_group,
mock_interaction, mock_admin_interaction,
key='rest', key='rest',
name=None, name=None,
category=None, category=None,
@ -401,15 +427,114 @@ class TestChartAdminCommands:
) )
@pytest.mark.asyncio @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.""" """Test updating with invalid category."""
with pytest.raises(BotException, match="Invalid category"): with pytest.raises(BotException, match="Invalid category"):
await admin_cog.chart_update.callback( await manage_group.update.callback(
admin_cog, manage_group,
mock_interaction, mock_admin_interaction,
key='rest', key='rest',
name=None, name=None,
category='invalid-category', category='invalid-category',
url=None, url=None,
description=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'