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)
- 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**

View File

@ -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

View File

@ -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."""

View File

@ -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

View File

@ -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.

View File

@ -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(

View File

@ -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)

View File

@ -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.

View File

@ -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": {

View File

@ -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

View File

@ -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())

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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}"

View File

@ -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()

View File

@ -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'