Transactions cog in place
This commit is contained in:
parent
7b41520054
commit
13c61fd8ae
@ -1,326 +0,0 @@
|
||||
# Discord Bot v2.0 - Logging Decorator Migration Guide
|
||||
|
||||
This guide documents the process for migrating existing Discord commands to use the new `@logged_command` decorator, which eliminates boilerplate logging code and standardizes command logging patterns.
|
||||
|
||||
## Overview
|
||||
|
||||
The `@logged_command` decorator automatically handles:
|
||||
- Discord context setting with interaction details
|
||||
- Operation timing and trace ID generation
|
||||
- Command start/completion/failure logging
|
||||
- Exception handling and logging
|
||||
- Parameter logging with exclusion options
|
||||
|
||||
## What Was Changed
|
||||
|
||||
### Before (Manual Logging Pattern)
|
||||
```python
|
||||
@discord.app_commands.command(name="roster", description="Display team roster")
|
||||
async def team_roster(self, interaction: discord.Interaction, abbrev: str):
|
||||
set_discord_context(interaction=interaction, command="/roster")
|
||||
trace_id = logger.start_operation("team_roster_command")
|
||||
|
||||
try:
|
||||
logger.info("Team roster command started")
|
||||
# Business logic here
|
||||
logger.info("Team roster command completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Team roster command failed", error=e)
|
||||
# Error handling
|
||||
|
||||
finally:
|
||||
logger.end_operation(trace_id)
|
||||
```
|
||||
|
||||
### After (With Decorator)
|
||||
```python
|
||||
@discord.app_commands.command(name="roster", description="Display team roster")
|
||||
@logged_command("/roster")
|
||||
async def team_roster(self, interaction: discord.Interaction, abbrev: str):
|
||||
# Business logic only - no logging boilerplate needed
|
||||
# All try/catch/finally logging is handled automatically
|
||||
```
|
||||
|
||||
## Step-by-Step Migration Process
|
||||
|
||||
### 1. Update Imports
|
||||
|
||||
**Add the decorator import:**
|
||||
```python
|
||||
from utils.decorators import logged_command
|
||||
```
|
||||
|
||||
**Remove unused logging imports (if no longer needed):**
|
||||
```python
|
||||
# Remove if not used elsewhere in the file:
|
||||
from utils.logging import set_discord_context # Usually can be removed
|
||||
```
|
||||
|
||||
### 2. Ensure Class Has Logger
|
||||
|
||||
**Before migration, ensure the command class has a logger:**
|
||||
```python
|
||||
class YourCommandCog(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.logger = get_contextual_logger(f'{__name__}.YourCommandCog') # Add this line
|
||||
```
|
||||
|
||||
### 3. Apply the Decorator
|
||||
|
||||
**Add the decorator above the command method:**
|
||||
```python
|
||||
@discord.app_commands.command(name="your-command", description="...")
|
||||
@logged_command("/your-command") # Add this line
|
||||
async def your_command_method(self, interaction, ...):
|
||||
```
|
||||
|
||||
### 4. Remove Manual Logging Boilerplate
|
||||
|
||||
**Remove these patterns:**
|
||||
- `set_discord_context(interaction=interaction, command="...")`
|
||||
- `trace_id = logger.start_operation("...")`
|
||||
- `try:` / `except:` / `finally:` blocks used only for logging
|
||||
- `logger.info("Command started")` and `logger.info("Command completed")`
|
||||
- `logger.error("Command failed", error=e)` in catch blocks
|
||||
- `logger.end_operation(trace_id)`
|
||||
|
||||
**Keep these:**
|
||||
- Business logic logging (e.g., `logger.info("Team found", team_id=123)`)
|
||||
- Specific error handling (user-facing error messages)
|
||||
- All business logic and Discord interaction code
|
||||
|
||||
### 5. Test the Migration
|
||||
|
||||
**Run the tests to ensure the migration works:**
|
||||
```bash
|
||||
python -m pytest tests/test_utils_decorators.py -v
|
||||
python -m pytest # Run all tests to ensure no regressions
|
||||
```
|
||||
|
||||
## Example: Complete Migration
|
||||
|
||||
### commands/teams/roster.py (BEFORE)
|
||||
```python
|
||||
"""Team roster commands for Discord Bot v2.0"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from utils.logging import get_contextual_logger, set_discord_context
|
||||
|
||||
class TeamRosterCommands(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
# Missing: self.logger = get_contextual_logger(...)
|
||||
|
||||
@discord.app_commands.command(name="roster", description="Display team roster")
|
||||
async def team_roster(self, interaction: discord.Interaction, abbrev: str):
|
||||
set_discord_context(interaction=interaction, command="/roster")
|
||||
trace_id = logger.start_operation("team_roster_command")
|
||||
|
||||
try:
|
||||
await interaction.response.defer()
|
||||
logger.info("Team roster command requested", team_abbrev=abbrev)
|
||||
|
||||
# Business logic
|
||||
team = await team_service.get_team_by_abbrev(abbrev)
|
||||
# ... more business logic ...
|
||||
|
||||
logger.info("Team roster displayed successfully")
|
||||
|
||||
except BotException as e:
|
||||
logger.error("Bot error in team roster command", error=str(e))
|
||||
# Error handling
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error in team roster command", error=str(e))
|
||||
# Error handling
|
||||
|
||||
finally:
|
||||
logger.end_operation(trace_id)
|
||||
```
|
||||
|
||||
### commands/teams/roster.py (AFTER)
|
||||
```python
|
||||
"""Team roster commands for Discord Bot v2.0"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from utils.logging import get_contextual_logger
|
||||
from utils.decorators import logged_command # Added
|
||||
|
||||
class TeamRosterCommands(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.logger = get_contextual_logger(f'{__name__}.TeamRosterCommands') # Added
|
||||
|
||||
@discord.app_commands.command(name="roster", description="Display team roster")
|
||||
@logged_command("/roster") # Added
|
||||
async def team_roster(self, interaction: discord.Interaction, abbrev: str):
|
||||
await interaction.response.defer()
|
||||
|
||||
# Business logic only - all boilerplate logging removed
|
||||
team = await team_service.get_team_by_abbrev(abbrev)
|
||||
|
||||
if team is None:
|
||||
self.logger.info("Team not found", team_abbrev=abbrev) # Business logic logging
|
||||
# ... handle not found ...
|
||||
return
|
||||
|
||||
# ... rest of business logic ...
|
||||
|
||||
self.logger.info("Team roster displayed successfully", # Business logic logging
|
||||
team_id=team.id, team_abbrev=team.abbrev)
|
||||
```
|
||||
|
||||
## Migration Checklist for Each Command
|
||||
|
||||
- [ ] Add `from utils.decorators import logged_command` import
|
||||
- [ ] Ensure class has `self.logger = get_contextual_logger(...)` in `__init__`
|
||||
- [ ] Add `@logged_command("/command-name")` decorator
|
||||
- [ ] Remove `set_discord_context()` call
|
||||
- [ ] Remove `trace_id = logger.start_operation()` call
|
||||
- [ ] Remove `try:` block (if only used for logging)
|
||||
- [ ] Remove `logger.info("Command started")` and `logger.info("Command completed")`
|
||||
- [ ] Remove generic `except Exception as e:` blocks (if only used for logging)
|
||||
- [ ] Remove `logger.error("Command failed")` calls
|
||||
- [ ] Remove `finally:` block and `logger.end_operation()` call
|
||||
- [ ] Keep business logic logging (specific info/debug/warning messages)
|
||||
- [ ] Keep error handling that sends user-facing messages
|
||||
- [ ] Test the command works correctly
|
||||
|
||||
## Decorator Options
|
||||
|
||||
### Basic Usage
|
||||
```python
|
||||
@logged_command("/command-name")
|
||||
async def my_command(self, interaction, param1: str):
|
||||
# Implementation
|
||||
```
|
||||
|
||||
### Auto-Detect Command Name
|
||||
```python
|
||||
@logged_command() # Will use "/my-command" based on function name
|
||||
async def my_command(self, interaction, param1: str):
|
||||
# Implementation
|
||||
```
|
||||
|
||||
### Exclude Sensitive Parameters
|
||||
```python
|
||||
@logged_command("/login", exclude_params=["password", "token"])
|
||||
async def login_command(self, interaction, username: str, password: str):
|
||||
# password won't appear in logs
|
||||
```
|
||||
|
||||
### Disable Parameter Logging
|
||||
```python
|
||||
@logged_command("/sensitive-command", log_params=False)
|
||||
async def sensitive_command(self, interaction, sensitive_data: str):
|
||||
# No parameters will be logged
|
||||
```
|
||||
|
||||
## Expected Benefits
|
||||
|
||||
### Lines of Code Reduction
|
||||
- **Before**: ~25-35 lines per command (including try/catch/finally)
|
||||
- **After**: ~10-15 lines per command
|
||||
- **Reduction**: ~15-20 lines of boilerplate per command
|
||||
|
||||
### Consistency Improvements
|
||||
- Standardized logging format across all commands
|
||||
- Consistent error handling patterns
|
||||
- Automatic trace ID generation and correlation
|
||||
- Reduced chance of logging bugs (forgotten `end_operation`, etc.)
|
||||
|
||||
### Maintainability
|
||||
- Single point of change for logging behavior
|
||||
- Easier to add new logging features (e.g., performance metrics)
|
||||
- Less code duplication
|
||||
- Clearer separation of business logic and infrastructure
|
||||
|
||||
## Files to Migrate
|
||||
|
||||
Based on the current codebase structure, these files likely need migration:
|
||||
|
||||
```
|
||||
commands/
|
||||
├── league/
|
||||
│ └── info.py
|
||||
├── players/
|
||||
│ └── info.py
|
||||
└── teams/
|
||||
├── info.py
|
||||
└── roster.py # ✅ Already migrated (example)
|
||||
```
|
||||
|
||||
## Testing Migration
|
||||
|
||||
### 1. Unit Tests
|
||||
```bash
|
||||
# Test the decorator itself
|
||||
python -m pytest tests/test_utils_decorators.py -v
|
||||
|
||||
# Test migrated commands still work
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
### 2. Integration Testing
|
||||
```bash
|
||||
# Verify command registration still works
|
||||
python -c "
|
||||
import discord
|
||||
from commands.teams.roster import TeamRosterCommands
|
||||
from discord.ext import commands
|
||||
|
||||
intents = discord.Intents.default()
|
||||
bot = commands.Bot(command_prefix='!', intents=intents)
|
||||
cog = TeamRosterCommands(bot)
|
||||
print('✅ Command loads successfully')
|
||||
"
|
||||
```
|
||||
|
||||
### 3. Log Output Verification
|
||||
After migration, verify that log entries still contain:
|
||||
- Correct trace IDs for request correlation
|
||||
- Command start/completion messages
|
||||
- Error logging with exceptions
|
||||
- Business logic messages
|
||||
- Discord context (user_id, guild_id, etc.)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue**: `AttributeError: 'YourCog' object has no attribute 'logger'`
|
||||
**Solution**: Add `self.logger = get_contextual_logger(...)` to the cog's `__init__` method
|
||||
|
||||
**Issue**: Parameters not appearing in logs
|
||||
**Solution**: Check if parameters are in the `exclude_params` list or if `log_params=False`
|
||||
|
||||
**Issue**: Command not registering with Discord
|
||||
**Solution**: Ensure `@logged_command()` is placed AFTER `@discord.app_commands.command()`
|
||||
|
||||
**Issue**: Signature errors during command registration
|
||||
**Solution**: The decorator preserves signatures automatically; if issues persist, check Discord.py version compatibility
|
||||
|
||||
### Debugging Steps
|
||||
|
||||
1. Check that all imports are correct
|
||||
2. Verify logger exists on the cog instance
|
||||
3. Run unit tests to ensure decorator functionality
|
||||
4. Check log files for expected trace IDs and messages
|
||||
5. Test command execution in a development environment
|
||||
|
||||
## Migration Timeline
|
||||
|
||||
**Recommended approach**: Migrate one command at a time and test thoroughly before moving to the next.
|
||||
|
||||
1. **Phase 1**: Migrate simple commands (no complex error handling)
|
||||
2. **Phase 2**: Migrate commands with custom error handling
|
||||
3. **Phase 3**: Migrate complex commands with multiple operations
|
||||
4. **Phase 4**: Update documentation and add any additional decorator features
|
||||
|
||||
This approach ensures that any issues can be isolated and resolved before affecting multiple commands.
|
||||
2
bot.py
2
bot.py
@ -113,6 +113,7 @@ class SBABot(commands.Bot):
|
||||
from commands.league import setup_league
|
||||
from commands.custom_commands import setup_custom_commands
|
||||
from commands.admin import setup_admin
|
||||
from commands.transactions import setup_transactions
|
||||
|
||||
# Define command packages to load
|
||||
command_packages = [
|
||||
@ -121,6 +122,7 @@ class SBABot(commands.Bot):
|
||||
("league", setup_league),
|
||||
("custom_commands", setup_custom_commands),
|
||||
("admin", setup_admin),
|
||||
("transactions", setup_transactions),
|
||||
]
|
||||
|
||||
total_successful = 0
|
||||
|
||||
84
commands/league/README.md
Normal file
84
commands/league/README.md
Normal file
@ -0,0 +1,84 @@
|
||||
# League Commands
|
||||
|
||||
This directory contains Discord slash commands related to league-wide information and statistics.
|
||||
|
||||
## Files
|
||||
|
||||
### `info.py`
|
||||
- **Command**: `/league`
|
||||
- **Description**: Display current league status and information
|
||||
- **Functionality**: Shows current season/week, phase (regular season/playoffs/offseason), transaction status, trade deadlines, and league configuration
|
||||
- **Service Dependencies**: `league_service.get_current_state()`
|
||||
- **Key Features**:
|
||||
- Dynamic phase detection (offseason, playoffs, regular season)
|
||||
- Transaction freeze status
|
||||
- Trade deadline and playoff schedule information
|
||||
- Draft pick trading status
|
||||
|
||||
### `standings.py`
|
||||
- **Commands**:
|
||||
- `/standings` - Display league standings by division
|
||||
- `/playoff-picture` - Show current playoff picture and wild card race
|
||||
- **Parameters**:
|
||||
- `season`: Optional season number (defaults to current)
|
||||
- `division`: Optional division filter for standings
|
||||
- **Service Dependencies**: `standings_service`
|
||||
- **Key Features**:
|
||||
- Division-based standings display
|
||||
- Games behind calculations
|
||||
- Recent form statistics (home record, last 8 games, current streak)
|
||||
- Playoff cutoff visualization
|
||||
- Wild card race tracking
|
||||
|
||||
### `schedule.py`
|
||||
- **Commands**:
|
||||
- `/schedule` - Display game schedules
|
||||
- `/results` - Show recent game results
|
||||
- **Parameters**:
|
||||
- `season`: Optional season number (defaults to current)
|
||||
- `week`: Optional specific week filter
|
||||
- `team`: Optional team abbreviation filter
|
||||
- **Service Dependencies**: `schedule_service`
|
||||
- **Key Features**:
|
||||
- Weekly schedule views
|
||||
- Team-specific schedule filtering
|
||||
- Series grouping and summary
|
||||
- Recent/upcoming game overview
|
||||
- Game completion tracking
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Decorator Usage
|
||||
All commands use the `@logged_command` decorator pattern:
|
||||
- Eliminates boilerplate logging code
|
||||
- Provides consistent error handling
|
||||
- Automatic request tracing and timing
|
||||
|
||||
### Error Handling
|
||||
- Graceful fallbacks for missing data
|
||||
- User-friendly error messages
|
||||
- Ephemeral responses for errors
|
||||
|
||||
### Embed Structure
|
||||
- Uses `EmbedTemplate` for consistent styling
|
||||
- Color coding based on context (success/error/info)
|
||||
- Rich formatting with team logos and thumbnails
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **No league data available**: Check `league_service.get_current_state()` API endpoint
|
||||
2. **Standings not loading**: Verify `standings_service.get_standings_by_division()` returns valid data
|
||||
3. **Schedule commands failing**: Ensure `schedule_service` methods are properly handling season/week parameters
|
||||
|
||||
### Dependencies
|
||||
- `services.league_service`
|
||||
- `services.standings_service`
|
||||
- `services.schedule_service`
|
||||
- `utils.decorators.logged_command`
|
||||
- `views.embeds.EmbedTemplate`
|
||||
- `constants.SBA_CURRENT_SEASON`
|
||||
|
||||
### Testing
|
||||
Run tests with: `python -m pytest tests/test_commands_league.py -v`
|
||||
@ -4,6 +4,7 @@ League Schedule Commands
|
||||
Implements slash commands for displaying game schedules and results.
|
||||
"""
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
@ -42,27 +43,17 @@ class ScheduleCommands(commands.Cog):
|
||||
"""Display game schedule for a week or team."""
|
||||
await interaction.response.defer()
|
||||
|
||||
try:
|
||||
search_season = season or SBA_CURRENT_SEASON
|
||||
search_season = season or SBA_CURRENT_SEASON
|
||||
|
||||
if team:
|
||||
# Show team schedule
|
||||
await self._show_team_schedule(interaction, search_season, team, week)
|
||||
elif week:
|
||||
# Show specific week schedule
|
||||
await self._show_week_schedule(interaction, search_season, week)
|
||||
else:
|
||||
# Show recent/upcoming games
|
||||
await self._show_current_schedule(interaction, search_season)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Error retrieving schedule: {str(e)}"
|
||||
|
||||
if interaction.response.is_done():
|
||||
await interaction.followup.send(error_msg, ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message(error_msg, ephemeral=True)
|
||||
raise
|
||||
if team:
|
||||
# Show team schedule
|
||||
await self._show_team_schedule(interaction, search_season, team, week)
|
||||
elif week:
|
||||
# Show specific week schedule
|
||||
await self._show_week_schedule(interaction, search_season, week)
|
||||
else:
|
||||
# Show recent/upcoming games
|
||||
await self._show_current_schedule(interaction, search_season)
|
||||
|
||||
@discord.app_commands.command(
|
||||
name="results",
|
||||
@ -82,45 +73,35 @@ class ScheduleCommands(commands.Cog):
|
||||
"""Display recent game results."""
|
||||
await interaction.response.defer()
|
||||
|
||||
try:
|
||||
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)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Error retrieving results: {str(e)}"
|
||||
|
||||
if interaction.response.is_done():
|
||||
await interaction.followup.send(error_msg, ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message(error_msg, ephemeral=True)
|
||||
raise
|
||||
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."""
|
||||
@ -169,8 +150,10 @@ class ScheduleCommands(commands.Cog):
|
||||
self.logger.debug("Fetching current schedule overview", season=season)
|
||||
|
||||
# Get both recent and upcoming games
|
||||
recent_games = await schedule_service.get_recent_games(season, weeks_back=1)
|
||||
upcoming_games = await schedule_service.get_upcoming_games(season, weeks_ahead=1)
|
||||
recent_games, upcoming_games = await asyncio.gather(
|
||||
schedule_service.get_recent_games(season, weeks_back=1),
|
||||
schedule_service.get_upcoming_games(season, weeks_ahead=1)
|
||||
)
|
||||
|
||||
if not recent_games and not upcoming_games:
|
||||
await interaction.followup.send(
|
||||
|
||||
104
commands/players/README.md
Normal file
104
commands/players/README.md
Normal file
@ -0,0 +1,104 @@
|
||||
# Player Commands
|
||||
|
||||
This directory contains Discord slash commands for player information and statistics.
|
||||
|
||||
## Files
|
||||
|
||||
### `info.py`
|
||||
- **Command**: `/player`
|
||||
- **Description**: Display comprehensive player information and statistics
|
||||
- **Parameters**:
|
||||
- `name` (required): Player name to search for
|
||||
- `season` (optional): Season for statistics (defaults to current season)
|
||||
- **Service Dependencies**:
|
||||
- `player_service.get_players_by_name()`
|
||||
- `player_service.search_players_fuzzy()`
|
||||
- `player_service.get_player()`
|
||||
- `stats_service.get_player_stats()`
|
||||
|
||||
## Key Features
|
||||
|
||||
### Player Search
|
||||
- **Exact Name Matching**: Primary search method using player name
|
||||
- **Fuzzy Search Fallback**: If no exact match, suggests similar player names
|
||||
- **Multiple Player Handling**: When multiple players match, attempts exact match or asks user to be more specific
|
||||
- **Suggestion System**: Shows up to 10 suggested players with positions when no exact match found
|
||||
|
||||
### Player Information Display
|
||||
- **Basic Info**: Name, position(s), team, season
|
||||
- **Statistics Integration**:
|
||||
- Batting stats (AVG/OBP/SLG, OPS, wOBA, HR, RBI, runs, etc.)
|
||||
- Pitching stats (W-L record, ERA, WHIP, strikeouts, saves, etc.)
|
||||
- Two-way player detection and display
|
||||
- **Visual Elements**:
|
||||
- Team logo as author icon
|
||||
- Player card image as main image
|
||||
- Thumbnail priority: fancy card → headshot → team logo
|
||||
- Team color theming for embed
|
||||
|
||||
### Advanced Features
|
||||
- **Concurrent Data Fetching**: Player data and statistics retrieved in parallel for performance
|
||||
- **sWAR Display**: Shows Strat-o-Matic WAR value
|
||||
- **Multi-Position Support**: Displays all eligible positions
|
||||
- **Rich Error Handling**: Graceful fallbacks when data is unavailable
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Search Logic Flow
|
||||
1. Search by exact name in specified season
|
||||
2. If no results, try fuzzy search across all players
|
||||
3. If single result, display player card
|
||||
4. If multiple results, attempt exact name match
|
||||
5. If still multiple, show disambiguation list
|
||||
|
||||
### Performance Optimizations
|
||||
- `asyncio.gather()` for concurrent API calls
|
||||
- Efficient player data and statistics retrieval
|
||||
- Lazy loading of optional player images
|
||||
|
||||
### Error Handling
|
||||
- No players found: Suggests fuzzy matches
|
||||
- Multiple matches: Provides clarification options
|
||||
- Missing data: Shows partial information with clear indicators
|
||||
- API failures: Graceful degradation with fallback data
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Player not found**:
|
||||
- Check player name spelling
|
||||
- Verify player exists in the specified season
|
||||
- Use fuzzy search suggestions
|
||||
|
||||
2. **Statistics not loading**:
|
||||
- Verify `stats_service.get_player_stats()` API endpoint
|
||||
- Check if player has statistics for the requested season
|
||||
- Ensure season parameter is valid
|
||||
|
||||
3. **Images not displaying**:
|
||||
- Check player image URLs in database
|
||||
- Verify team thumbnail URLs
|
||||
- Ensure image hosting is accessible
|
||||
|
||||
4. **Performance issues**:
|
||||
- Monitor concurrent API call efficiency
|
||||
- Check database query performance
|
||||
- Verify embed size limits
|
||||
|
||||
### Dependencies
|
||||
- `services.player_service`
|
||||
- `services.stats_service`
|
||||
- `utils.decorators.logged_command`
|
||||
- `views.embeds.EmbedTemplate`
|
||||
- `constants.SBA_CURRENT_SEASON`
|
||||
- `exceptions.BotException`
|
||||
|
||||
### Testing
|
||||
Run tests with: `python -m pytest tests/test_commands_players.py -v`
|
||||
|
||||
## Database Requirements
|
||||
- Player records with name, positions, team associations
|
||||
- Statistics tables for batting and pitching
|
||||
- Image URLs for player cards, headshots, and fancy cards
|
||||
- Team logo and color information
|
||||
134
commands/teams/README.md
Normal file
134
commands/teams/README.md
Normal file
@ -0,0 +1,134 @@
|
||||
# Team Commands
|
||||
|
||||
This directory contains Discord slash commands for team information and roster management.
|
||||
|
||||
## Files
|
||||
|
||||
### `info.py`
|
||||
- **Commands**:
|
||||
- `/team` - Display comprehensive team information
|
||||
- `/teams` - List all teams in a season
|
||||
- **Parameters**:
|
||||
- `abbrev` (required for `/team`): Team abbreviation (e.g., NYY, BOS, LAD)
|
||||
- `season` (optional): Season to display (defaults to current season)
|
||||
- **Service Dependencies**:
|
||||
- `team_service.get_team_by_abbrev()`
|
||||
- `team_service.get_teams_by_season()`
|
||||
- `team_service.get_team_standings_position()`
|
||||
|
||||
### `roster.py`
|
||||
- **Command**: `/roster`
|
||||
- **Description**: Display detailed team roster with position breakdowns
|
||||
- **Parameters**:
|
||||
- `abbrev` (required): Team abbreviation
|
||||
- `roster_type` (optional): "current" or "next" week roster (defaults to current)
|
||||
- **Service Dependencies**:
|
||||
- `team_service.get_team_by_abbrev()`
|
||||
- `team_service.get_team_roster()`
|
||||
|
||||
## Key Features
|
||||
|
||||
### Team Information Display (`info.py`)
|
||||
- **Comprehensive Team Data**:
|
||||
- Team names (long name, short name, abbreviation)
|
||||
- Stadium information
|
||||
- Division assignment
|
||||
- Team colors and logos
|
||||
- **Standings Integration**:
|
||||
- Win-loss record and winning percentage
|
||||
- Games behind division leader
|
||||
- Current standings position
|
||||
- **Visual Elements**:
|
||||
- Team color theming for embeds
|
||||
- Team logo thumbnails
|
||||
- Consistent branding across displays
|
||||
|
||||
### Team Listing (`/teams`)
|
||||
- **Season Overview**: All teams organized by division
|
||||
- **Division Grouping**: Automatically groups teams by division ID
|
||||
- **Fallback Display**: Shows simple list if division data unavailable
|
||||
- **Team Count**: Total team summary
|
||||
|
||||
### Roster Management (`roster.py`)
|
||||
- **Multi-Week Support**: Current and next week roster views
|
||||
- **Position Breakdown**:
|
||||
- Batting positions (C, 1B, 2B, 3B, SS, LF, CF, RF, DH)
|
||||
- Pitching positions (SP, RP, CP)
|
||||
- Position player counts and totals
|
||||
- **Advanced Features**:
|
||||
- Total sWAR calculation and display
|
||||
- Minor League (shortil) player tracking
|
||||
- Injured List (longil) player management
|
||||
- Detailed player lists with positions and WAR values
|
||||
|
||||
### Roster Display Structure
|
||||
- **Summary Embed**: Position counts and totals
|
||||
- **Detailed Player Lists**: Separate embeds for each roster type
|
||||
- **Player Organization**: Batters and pitchers grouped separately
|
||||
- **Chunked Display**: Long player lists split across multiple fields
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Embed Design
|
||||
- **Team Color Integration**: Uses team hex colors for embed theming
|
||||
- **Fallback Colors**: Default colors when team colors unavailable
|
||||
- **Thumbnail Priority**: Team logos displayed consistently
|
||||
- **Multi-Embed Support**: Complex data split across multiple embeds
|
||||
|
||||
### Error Handling
|
||||
- **Team Not Found**: Clear messaging with season context
|
||||
- **Missing Roster Data**: Graceful handling of unavailable data
|
||||
- **API Failures**: Fallback to partial information display
|
||||
|
||||
### Performance Considerations
|
||||
- **Concurrent Data Fetching**: Standings and roster data retrieved in parallel
|
||||
- **Efficient Roster Processing**: Position grouping and calculations optimized
|
||||
- **Chunked Player Lists**: Prevents Discord embed size limits
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Team not found**:
|
||||
- Verify team abbreviation spelling
|
||||
- Check if team exists in the specified season
|
||||
- Ensure abbreviation matches database format
|
||||
|
||||
2. **Roster data missing**:
|
||||
- Verify `team_service.get_team_roster()` API endpoint
|
||||
- Check if roster data exists for the requested week type
|
||||
- Ensure team ID is correctly passed to roster service
|
||||
|
||||
3. **Position counts incorrect**:
|
||||
- Verify roster data structure and position field names
|
||||
- Check sWAR calculation logic
|
||||
- Ensure player position arrays are properly parsed
|
||||
|
||||
4. **Standings not displaying**:
|
||||
- Check `get_team_standings_position()` API response
|
||||
- Verify standings data structure matches expected format
|
||||
- Ensure error handling for malformed standings data
|
||||
|
||||
### Dependencies
|
||||
- `services.team_service`
|
||||
- `models.team.Team`
|
||||
- `utils.decorators.logged_command`
|
||||
- `views.embeds.EmbedTemplate`
|
||||
- `constants.SBA_CURRENT_SEASON`
|
||||
- `exceptions.BotException`
|
||||
|
||||
### Testing
|
||||
Run tests with: `python -m pytest tests/test_commands_teams.py -v`
|
||||
|
||||
## Database Requirements
|
||||
- Team records with abbreviations, names, colors, logos
|
||||
- Division assignment and organization
|
||||
- Roster data with position assignments and player details
|
||||
- Standings calculations and team statistics
|
||||
- Stadium and venue information
|
||||
|
||||
## Future Enhancements
|
||||
- Team statistics and performance metrics
|
||||
- Historical team data and comparisons
|
||||
- Roster change tracking and transaction history
|
||||
- Advanced roster analytics and projections
|
||||
243
commands/transactions/DESIGN.md
Normal file
243
commands/transactions/DESIGN.md
Normal file
@ -0,0 +1,243 @@
|
||||
# Transaction System Design - Discord Bot v2.0
|
||||
|
||||
## Analysis of Existing System
|
||||
|
||||
Based on the analysis of `../discord-app/cogs/transactions.py`, here are the key components and expected outcomes:
|
||||
|
||||
## Core Commands & Expected Outcomes
|
||||
|
||||
### 1. `/dropadd` (Primary Transaction Command)
|
||||
**Purpose**: Handle free agent signings and minor league roster moves
|
||||
**Expected Outcomes**:
|
||||
- Create private transaction channel for team
|
||||
- Guide user through interactive transaction process
|
||||
- Add/drop players to/from Major League and Minor League rosters
|
||||
- Validate roster limits and legality
|
||||
- Schedule transactions for next week execution
|
||||
- Log all moves for processing during freeze period
|
||||
|
||||
**User Flow**:
|
||||
1. User runs `/dropadd`
|
||||
2. Bot creates private channel with team permissions
|
||||
3. Interactive Q&A for Minor League adds/drops
|
||||
4. Interactive Q&A for Major League adds/drops
|
||||
5. Roster validation and error checking
|
||||
6. Transaction confirmation and scheduling
|
||||
|
||||
### 2. `/ilmove` (Injury List Management)
|
||||
**Purpose**: Handle injured list moves during active weeks
|
||||
**Expected Outcomes**:
|
||||
- Move players to/from injury list immediately (not next week)
|
||||
- Validate IL move legality
|
||||
- Update current roster immediately
|
||||
- Log IL transaction
|
||||
|
||||
### 3. `/rule34` (Draft Lottery)
|
||||
**Purpose**: 50/50 chance between actual draft board or "rule34" redirect
|
||||
**Expected Outcomes**:
|
||||
- 50% chance: Redirect to legitimate draft spreadsheet
|
||||
- 50% chance: Redirect to rule34 search (league humor)
|
||||
|
||||
### 4. `/mymoves` (Transaction Status)
|
||||
**Purpose**: Show user's pending and scheduled transactions
|
||||
**Expected Outcomes**:
|
||||
- Display current week pending moves
|
||||
- Display next week scheduled moves
|
||||
- Show frozen/processed moves
|
||||
- Option to show cancelled moves
|
||||
- Organized by week and status
|
||||
|
||||
### 5. `/legal` (Roster Validation)
|
||||
**Purpose**: Check roster legality for current and next week
|
||||
**Expected Outcomes**:
|
||||
- Validate current week roster
|
||||
- Validate next week roster (with pending transactions)
|
||||
- Check roster limits (players, positions, salary, etc.)
|
||||
- Display violations with clear error messages
|
||||
- Show WARA totals and roster breakdowns
|
||||
|
||||
### 6. `/tomil` (Post-Draft Demotions)
|
||||
**Purpose**: Move players to minor leagues immediately after draft (Week 0 only)
|
||||
**Expected Outcomes**:
|
||||
- Only works during Week 0 (post-draft)
|
||||
- Immediate player moves to MiL team
|
||||
- Validate moves are legal
|
||||
- Punishment mechanism for use outside Week 0 (role removal)
|
||||
|
||||
## Background Processing
|
||||
|
||||
### 7. Weekly Transaction Processing
|
||||
**Purpose**: Automated transaction execution during freeze periods
|
||||
**Expected Outcomes**:
|
||||
- Freeze period begins: Monday 12:00 AM (development schedule)
|
||||
- Process all pending transactions with priority system
|
||||
- Resolve contested transactions (multiple teams want same player)
|
||||
- Update rosters for new week
|
||||
- Send transaction logs to transaction-log channel
|
||||
- Freeze period ends: Saturday 12:00 AM
|
||||
|
||||
### 8. Transaction Priority System
|
||||
**Expected Outcomes**:
|
||||
- Priority 1: Major League transactions (higher priority)
|
||||
- Priority 2: Minor League transactions (lower priority)
|
||||
- Within priority: Worse record gets priority (lower win %)
|
||||
- Tie-breaker: Random number
|
||||
- Contested players go to highest priority team
|
||||
|
||||
## Data Models Needed
|
||||
|
||||
### Transaction Model
|
||||
```python
|
||||
@dataclass
|
||||
class Transaction:
|
||||
id: str
|
||||
season: int
|
||||
week: int
|
||||
team_abbrev: str
|
||||
move_type: str # 'dropadd', 'ilmove', 'tomil'
|
||||
moves: List[PlayerMove]
|
||||
status: str # 'pending', 'frozen', 'processed', 'cancelled'
|
||||
created_at: datetime
|
||||
processed_at: Optional[datetime]
|
||||
```
|
||||
|
||||
### PlayerMove Model
|
||||
```python
|
||||
@dataclass
|
||||
class PlayerMove:
|
||||
player_name: str
|
||||
player_id: int
|
||||
from_team: str
|
||||
to_team: str
|
||||
move_type: str # 'add', 'drop', 'il_to_active', 'active_to_il'
|
||||
```
|
||||
|
||||
### TransactionPriority Model (exists)
|
||||
```python
|
||||
@dataclass
|
||||
class TransactionPriority:
|
||||
roster_priority: int # 1=Major, 2=Minor
|
||||
win_percentage: float
|
||||
random_tiebreaker: int
|
||||
move_id: str
|
||||
major_league_team_abbrev: str
|
||||
contested_players: List[str]
|
||||
```
|
||||
|
||||
## Services Needed
|
||||
|
||||
### TransactionService
|
||||
- CRUD operations for transactions
|
||||
- Transaction validation logic
|
||||
- Roster checking and limits
|
||||
- Player availability checking
|
||||
|
||||
### RosterService
|
||||
- Get current/next week rosters
|
||||
- Validate roster legality
|
||||
- Calculate roster statistics (WARA, positions, etc.)
|
||||
- Handle roster updates
|
||||
|
||||
### TransactionProcessorService
|
||||
- Weekly freeze period processing
|
||||
- Priority calculation and resolution
|
||||
- Contested transaction resolution
|
||||
- Roster updates and notifications
|
||||
|
||||
## Modernization Changes
|
||||
|
||||
### From Old System → New System
|
||||
|
||||
1. **Commands**: `@commands.command` → `@app_commands.command` with `@logged_command`
|
||||
2. **Error Handling**: Manual try/catch → Decorator-based standardized handling
|
||||
3. **Interactive Flow**: Old Question class → Discord Views with buttons/modals
|
||||
4. **Database**: Direct db_calls → Service layer with proper models
|
||||
5. **Logging**: Manual logging → Automatic with trace IDs
|
||||
6. **Validation**: Inline validation → Service-based validation with proper error types
|
||||
7. **Channel Management**: Manual channel creation → Managed transaction sessions
|
||||
8. **User Experience**: Text-based Q&A → Rich embeds with interactive components
|
||||
|
||||
## Pseudo-Code Design
|
||||
|
||||
```python
|
||||
# Main Transaction Commands
|
||||
class TransactionCommands(commands.Cog):
|
||||
|
||||
@app_commands.command(name="dropadd", description="Make roster moves for next week")
|
||||
@logged_command("/dropadd")
|
||||
async def dropadd(self, interaction: discord.Interaction):
|
||||
# 1. Validate user has team and season is active
|
||||
# 2. Create transaction session with private thread/channel
|
||||
# 3. Launch TransactionFlow view with buttons for different move types
|
||||
# 4. Handle interactive transaction building
|
||||
# 5. Validate final transaction
|
||||
# 6. Save and schedule transaction
|
||||
|
||||
@app_commands.command(name="ilmove", description="Make immediate injury list moves")
|
||||
@logged_command("/ilmove")
|
||||
async def ilmove(self, interaction: discord.Interaction):
|
||||
# 1. Validate current week (not freeze period)
|
||||
# 2. Launch ILMoveView for player selection
|
||||
# 3. Process move immediately (no scheduling)
|
||||
# 4. Update current roster
|
||||
|
||||
@app_commands.command(name="mymoves", description="View your pending transactions")
|
||||
@logged_command("/mymoves")
|
||||
async def mymoves(self, interaction: discord.Interaction):
|
||||
# 1. Get user's team
|
||||
# 2. Fetch pending/scheduled transactions
|
||||
# 3. Create comprehensive embed with transaction status
|
||||
|
||||
@app_commands.command(name="legal", description="Check roster legality")
|
||||
@logged_command("/legal")
|
||||
async def legal(self, interaction: discord.Interaction, team: Optional[str] = None):
|
||||
# 1. Get target team (user's team or specified team)
|
||||
# 2. Validate current week roster
|
||||
# 3. Validate next week roster (with pending transactions)
|
||||
# 4. Create detailed legality report embed
|
||||
|
||||
# Interactive Views
|
||||
class TransactionFlowView(discord.ui.View):
|
||||
# Modern Discord UI for transaction building
|
||||
# Buttons for: Add Player, Drop Player, Minor League, Done
|
||||
# Modal dialogs for player input
|
||||
# Real-time validation feedback
|
||||
|
||||
class ILMoveView(discord.ui.View):
|
||||
# Injury list move interface
|
||||
# Player selection dropdown
|
||||
# Direction buttons (IL → Active, Active → IL)
|
||||
|
||||
# Services
|
||||
class TransactionService(BaseService[Transaction]):
|
||||
async def create_transaction(team_id: int, moves: List[PlayerMove]) -> Transaction
|
||||
async def validate_transaction(transaction: Transaction) -> ValidationResult
|
||||
async def get_pending_transactions(team_id: int) -> List[Transaction]
|
||||
|
||||
class RosterService:
|
||||
async def get_roster(team_id: int, week: str) -> TeamRoster # 'current' or 'next'
|
||||
async def validate_roster_legality(roster: TeamRoster) -> RosterValidation
|
||||
async def apply_transaction(roster: TeamRoster, transaction: Transaction) -> TeamRoster
|
||||
|
||||
class TransactionProcessor:
|
||||
async def process_weekly_transactions() -> ProcessingResult
|
||||
async def calculate_priorities(transactions: List[Transaction]) -> List[TransactionPriority]
|
||||
async def resolve_contests(transactions: List[Transaction]) -> ResolutionResult
|
||||
```
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **Phase 1**: Basic commands (`/mymoves`, `/legal`) - Read-only functionality
|
||||
2. **Phase 2**: Transaction models and services - Data layer
|
||||
3. **Phase 3**: Interactive transaction creation (`/dropadd`, `/ilmove`) - Core functionality
|
||||
4. **Phase 4**: Weekly processing system - Automation
|
||||
5. **Phase 5**: Advanced features (`/rule34`, `/tomil`) - Nice-to-have
|
||||
|
||||
## Key Modernization Benefits
|
||||
|
||||
- **User Experience**: Rich Discord UI instead of text-based Q&A
|
||||
- **Error Handling**: Comprehensive validation with clear error messages
|
||||
- **Performance**: Service layer with proper caching and concurrent operations
|
||||
- **Maintainability**: Clean separation of concerns, proper models, standardized patterns
|
||||
- **Reliability**: Proper transaction handling, rollback capabilities, audit logging
|
||||
- **Security**: Permission validation, input sanitization, rate limiting
|
||||
130
commands/transactions/README.md
Normal file
130
commands/transactions/README.md
Normal file
@ -0,0 +1,130 @@
|
||||
# Transaction Commands
|
||||
|
||||
This directory contains Discord slash commands for transaction management and roster legality checking.
|
||||
|
||||
## Files
|
||||
|
||||
### `management.py`
|
||||
- **Commands**:
|
||||
- `/mymoves` - View user's pending and scheduled transactions
|
||||
- `/legal` - Check roster legality for current and next week
|
||||
- **Service Dependencies**:
|
||||
- `transaction_service` (multiple methods for transaction retrieval)
|
||||
- `roster_service` (roster validation and retrieval)
|
||||
- `team_service.get_teams_by_owner()` and `get_team_by_abbrev()`
|
||||
|
||||
## Key Features
|
||||
|
||||
### Transaction Status Display (`/mymoves`)
|
||||
- **User Team Detection**: Automatically finds user's team by Discord ID
|
||||
- **Transaction Categories**:
|
||||
- **Pending**: Transactions awaiting processing
|
||||
- **Frozen**: Scheduled transactions ready for processing
|
||||
- **Processed**: Recently completed transactions
|
||||
- **Cancelled**: Optional display of cancelled transactions
|
||||
- **Status Visualization**:
|
||||
- Status emojis for each transaction type
|
||||
- Week numbering and move descriptions
|
||||
- Transaction count summaries
|
||||
- **Smart Limiting**: Shows recent transactions (last 5 pending, 3 frozen/processed, 2 cancelled)
|
||||
|
||||
### Roster Legality Checking (`/legal`)
|
||||
- **Dual Roster Validation**: Checks both current and next week rosters
|
||||
- **Flexible Team Selection**:
|
||||
- Auto-detects user's team
|
||||
- Allows manual team specification via abbreviation
|
||||
- **Comprehensive Validation**:
|
||||
- Player count verification (active roster + IL)
|
||||
- sWAR calculations and limits
|
||||
- League rule compliance checking
|
||||
- Error and warning categorization
|
||||
- **Parallel Processing**: Roster retrieval and validation run concurrently
|
||||
|
||||
### Advanced Transaction Features
|
||||
- **Concurrent Data Fetching**: Multiple transaction types retrieved in parallel
|
||||
- **Owner-Based Filtering**: Transactions filtered by team ownership
|
||||
- **Status Tracking**: Real-time transaction status with emoji indicators
|
||||
- **Team Integration**: Team logos and colors in transaction displays
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Permission Model
|
||||
- **Team Ownership**: Commands use Discord user ID to determine team ownership
|
||||
- **Cross-Team Viewing**: `/legal` allows checking other teams' roster status
|
||||
- **Access Control**: Users can only view their own transactions via `/mymoves`
|
||||
|
||||
### Data Processing
|
||||
- **Async Operations**: Heavy use of `asyncio.gather()` for performance
|
||||
- **Error Resilience**: Graceful handling of missing roster data
|
||||
- **Validation Pipeline**: Multi-step roster validation with detailed feedback
|
||||
|
||||
### Embed Structure
|
||||
- **Status-Based Coloring**: Success (green) vs Error (red) color coding
|
||||
- **Information Hierarchy**: Important information prioritized in embed layout
|
||||
- **Team Branding**: Consistent use of team thumbnails and colors
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **User team not found**:
|
||||
- Verify user has team ownership record in database
|
||||
- Check Discord user ID mapping to team ownership
|
||||
- Ensure current season team assignments are correct
|
||||
|
||||
2. **Transaction data missing**:
|
||||
- Verify `transaction_service` API endpoints are functional
|
||||
- Check transaction status filtering logic
|
||||
- Ensure transaction records exist for the team/season
|
||||
|
||||
3. **Roster validation failing**:
|
||||
- Check `roster_service.get_current_roster()` and `get_next_roster()` responses
|
||||
- Verify roster validation rules and logic
|
||||
- Ensure player data integrity in roster records
|
||||
|
||||
4. **Legal command errors**:
|
||||
- Verify team abbreviation exists in database
|
||||
- Check roster data availability for both current and next weeks
|
||||
- Ensure validation service handles edge cases properly
|
||||
|
||||
### Service Dependencies
|
||||
- `services.transaction_service`:
|
||||
- `get_pending_transactions()`
|
||||
- `get_frozen_transactions()`
|
||||
- `get_processed_transactions()`
|
||||
- `get_team_transactions()`
|
||||
- `services.roster_service`:
|
||||
- `get_current_roster()`
|
||||
- `get_next_roster()`
|
||||
- `validate_roster()`
|
||||
- `services.team_service`:
|
||||
- `get_teams_by_owner()`
|
||||
- `get_team_by_abbrev()`
|
||||
|
||||
### Core Dependencies
|
||||
- `utils.decorators.logged_command`
|
||||
- `views.embeds.EmbedTemplate`
|
||||
- `constants.SBA_CURRENT_SEASON`
|
||||
|
||||
### Testing
|
||||
Run tests with: `python -m pytest tests/test_commands_transactions.py -v`
|
||||
|
||||
## Database Requirements
|
||||
- Team ownership mapping (Discord user ID to team)
|
||||
- Transaction records with status tracking
|
||||
- Roster data for current and next weeks
|
||||
- Player assignments and position information
|
||||
- League rules and validation criteria
|
||||
|
||||
## Future Enhancements
|
||||
- Transaction submission and modification commands
|
||||
- Advanced transaction analytics and history
|
||||
- Roster optimization suggestions
|
||||
- Transaction approval workflow integration
|
||||
- Automated roster validation alerts
|
||||
|
||||
## Security Considerations
|
||||
- User authentication via Discord IDs
|
||||
- Team ownership verification for sensitive operations
|
||||
- Transaction privacy (users can only see their own transactions)
|
||||
- Input validation for team abbreviations and parameters
|
||||
52
commands/transactions/__init__.py
Normal file
52
commands/transactions/__init__.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""
|
||||
Transaction command package for Discord Bot v2.0
|
||||
|
||||
Contains transaction management commands for league operations.
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Tuple, Type
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from .management import TransactionCommands
|
||||
from .dropadd import DropAddCommands
|
||||
|
||||
logger = logging.getLogger(f'{__name__}.setup_transactions')
|
||||
|
||||
|
||||
async def setup_transactions(bot: commands.Bot) -> Tuple[int, int, List[str]]:
|
||||
"""
|
||||
Set up transaction command modules.
|
||||
|
||||
Returns:
|
||||
Tuple of (successful_loads, failed_loads, failed_modules)
|
||||
"""
|
||||
transaction_cogs: List[Tuple[str, Type[commands.Cog]]] = [
|
||||
("TransactionCommands", TransactionCommands),
|
||||
("DropAddCommands", DropAddCommands),
|
||||
]
|
||||
|
||||
successful = 0
|
||||
failed = 0
|
||||
failed_modules = []
|
||||
|
||||
for cog_name, cog_class in transaction_cogs:
|
||||
try:
|
||||
await bot.add_cog(cog_class(bot))
|
||||
logger.info(f"✅ Loaded transaction command module: {cog_name}")
|
||||
successful += 1
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to load transaction command module {cog_name}: {e}")
|
||||
failed += 1
|
||||
failed_modules.append(cog_name)
|
||||
|
||||
# Log summary
|
||||
if failed == 0:
|
||||
logger.info(f"🎉 All {successful} transaction command modules loaded successfully")
|
||||
else:
|
||||
logger.warning(f"⚠️ Transaction commands loaded with issues: {successful} successful, {failed} failed")
|
||||
if failed_modules:
|
||||
logger.warning(f"Failed modules: {', '.join(failed_modules)}")
|
||||
|
||||
return successful, failed, failed_modules
|
||||
275
commands/transactions/dropadd.py
Normal file
275
commands/transactions/dropadd.py
Normal file
@ -0,0 +1,275 @@
|
||||
"""
|
||||
Modern /dropadd Command
|
||||
|
||||
Interactive transaction builder with real-time validation and elegant UX.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
|
||||
from utils.logging import get_contextual_logger
|
||||
from utils.decorators import logged_command
|
||||
from constants import SBA_CURRENT_SEASON
|
||||
|
||||
from services.transaction_builder import (
|
||||
TransactionBuilder,
|
||||
RosterType,
|
||||
TransactionMove,
|
||||
get_transaction_builder,
|
||||
clear_transaction_builder
|
||||
)
|
||||
from services.player_service import player_service
|
||||
from services.team_service import team_service
|
||||
from views.transaction_embed import TransactionEmbedView, create_transaction_embed
|
||||
|
||||
|
||||
class DropAddCommands(commands.Cog):
|
||||
"""Modern transaction builder commands."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.logger = get_contextual_logger(f'{__name__}.DropAddCommands')
|
||||
|
||||
async def player_autocomplete(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
current: str
|
||||
) -> List[app_commands.Choice[str]]:
|
||||
"""
|
||||
Autocomplete for player names.
|
||||
|
||||
Args:
|
||||
interaction: Discord interaction
|
||||
current: Current input from user
|
||||
|
||||
Returns:
|
||||
List of player name choices
|
||||
"""
|
||||
if len(current) < 2:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Search for players using the new dedicated search endpoint
|
||||
players = await player_service.search_players(current, limit=25, season=SBA_CURRENT_SEASON)
|
||||
|
||||
# Format choices for Discord autocomplete
|
||||
choices = []
|
||||
for player in players:
|
||||
# Format: "Player Name (POS - TEAM)"
|
||||
team_info = f"{player.primary_position}"
|
||||
if hasattr(player, 'team') and player.team:
|
||||
team_info += f" - {player.team.abbrev}"
|
||||
|
||||
choice_name = f"{player.name} ({team_info})"
|
||||
choices.append(app_commands.Choice(name=choice_name, value=player.name))
|
||||
|
||||
return choices
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in player autocomplete: {e}")
|
||||
return []
|
||||
|
||||
@app_commands.command(
|
||||
name="dropadd",
|
||||
description="Interactive transaction builder for player moves"
|
||||
)
|
||||
@app_commands.describe(
|
||||
player="Player name (optional - can add later)",
|
||||
destination="Where to move the player: Major League, Minor League, Injured List, or Free Agency"
|
||||
)
|
||||
@app_commands.autocomplete(player=player_autocomplete)
|
||||
@app_commands.choices(destination=[
|
||||
app_commands.Choice(name="Major League", value="ml"),
|
||||
app_commands.Choice(name="Minor League", value="mil"),
|
||||
app_commands.Choice(name="Injured List", value="il"),
|
||||
app_commands.Choice(name="Free Agency", value="fa")
|
||||
])
|
||||
@logged_command("/dropadd")
|
||||
async def dropadd(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
player: Optional[str] = None,
|
||||
destination: Optional[str] = None
|
||||
):
|
||||
"""Interactive transaction builder for complex player moves."""
|
||||
await interaction.response.defer()
|
||||
|
||||
# Get user's major league team
|
||||
major_league_teams = await team_service.get_teams_by_owner(
|
||||
interaction.user.id,
|
||||
SBA_CURRENT_SEASON,
|
||||
roster_type="ml"
|
||||
)
|
||||
|
||||
if not major_league_teams:
|
||||
await interaction.followup.send(
|
||||
"❌ You don't appear to own a major league team in the current season.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
team = major_league_teams[0] # Use first major league team
|
||||
|
||||
# Get or create transaction builder
|
||||
builder = get_transaction_builder(interaction.user.id, team)
|
||||
|
||||
# If player and destination provided, try to add the move immediately
|
||||
if player and destination:
|
||||
success = await self._add_quick_move(builder, player, destination)
|
||||
if success:
|
||||
self.logger.info(f"Quick move added for {team.abbrev}: {player} → {destination}")
|
||||
else:
|
||||
self.logger.warning(f"Failed to add quick move: {player} → {destination}")
|
||||
|
||||
# Create and display interactive embed
|
||||
embed = await create_transaction_embed(builder)
|
||||
view = TransactionEmbedView(builder, interaction.user.id)
|
||||
|
||||
await interaction.followup.send(embed=embed, view=view)
|
||||
|
||||
async def _add_quick_move(
|
||||
self,
|
||||
builder: TransactionBuilder,
|
||||
player_name: str,
|
||||
destination_str: str
|
||||
) -> bool:
|
||||
"""
|
||||
Add a move quickly from command parameters by auto-determining the action.
|
||||
|
||||
Args:
|
||||
builder: TransactionBuilder instance
|
||||
player_name: Name of player to move
|
||||
destination_str: Destination string (ml, mil, fa)
|
||||
|
||||
Returns:
|
||||
True if move was added successfully
|
||||
"""
|
||||
try:
|
||||
# Find player using the new search endpoint
|
||||
players = await player_service.search_players(player_name, limit=10, season=SBA_CURRENT_SEASON)
|
||||
if not players:
|
||||
self.logger.error(f"Player not found: {player_name}")
|
||||
return False
|
||||
|
||||
# Use exact match if available, otherwise first result
|
||||
player = None
|
||||
for p in players:
|
||||
if p.name.lower() == player_name.lower():
|
||||
player = p
|
||||
break
|
||||
|
||||
if not player:
|
||||
player = players[0] # Use first match
|
||||
|
||||
# Parse destination
|
||||
destination_map = {
|
||||
"ml": RosterType.MAJOR_LEAGUE,
|
||||
"mil": RosterType.MINOR_LEAGUE,
|
||||
"il": RosterType.INJURED_LIST,
|
||||
"fa": RosterType.FREE_AGENCY
|
||||
}
|
||||
|
||||
to_roster = destination_map.get(destination_str.lower())
|
||||
if not to_roster:
|
||||
self.logger.error(f"Invalid destination: {destination_str}")
|
||||
return False
|
||||
|
||||
# Determine player's current roster status based on their team and roster type
|
||||
if player.team_id == builder.team.id:
|
||||
# Player is on the user's team - need to determine which roster
|
||||
# This would need to be enhanced to check actual roster data
|
||||
# For now, we'll assume they're coming from Major League
|
||||
from_roster = RosterType.MAJOR_LEAGUE
|
||||
else:
|
||||
# Player is on another team or free agency
|
||||
from_roster = RosterType.FREE_AGENCY
|
||||
|
||||
# Create move
|
||||
move = TransactionMove(
|
||||
player=player,
|
||||
from_roster=from_roster,
|
||||
to_roster=to_roster,
|
||||
from_team=None if from_roster == RosterType.FREE_AGENCY else builder.team,
|
||||
to_team=None if to_roster == RosterType.FREE_AGENCY else builder.team
|
||||
)
|
||||
|
||||
success, error_message = builder.add_move(move)
|
||||
if not success:
|
||||
self.logger.warning(f"Failed to add quick move: {error_message}")
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error adding quick move: {e}")
|
||||
return False
|
||||
|
||||
@app_commands.command(
|
||||
name="cleartransaction",
|
||||
description="Clear your current transaction builder"
|
||||
)
|
||||
@logged_command("/cleartransaction")
|
||||
async def clear_transaction(self, interaction: discord.Interaction):
|
||||
"""Clear the user's current transaction builder."""
|
||||
clear_transaction_builder(interaction.user.id)
|
||||
|
||||
await interaction.response.send_message(
|
||||
"✅ Your transaction builder has been cleared.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="transactionstatus",
|
||||
description="Show your current transaction builder status"
|
||||
)
|
||||
@logged_command("/transactionstatus")
|
||||
async def transaction_status(self, interaction: discord.Interaction):
|
||||
"""Show the current status of user's transaction builder."""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
# Get user's major league team
|
||||
major_league_teams = await team_service.get_teams_by_owner(
|
||||
interaction.user.id,
|
||||
SBA_CURRENT_SEASON,
|
||||
roster_type="ml"
|
||||
)
|
||||
|
||||
if not major_league_teams:
|
||||
await interaction.followup.send(
|
||||
"❌ You don't appear to own a major league team in the current season.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
team = major_league_teams[0]
|
||||
builder = get_transaction_builder(interaction.user.id, team)
|
||||
|
||||
if builder.is_empty:
|
||||
await interaction.followup.send(
|
||||
"📋 Your transaction builder is empty. Use `/dropadd` to start building!",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Show current status
|
||||
validation = await builder.validate_transaction()
|
||||
|
||||
status_msg = f"📋 **Transaction Builder Status - {team.abbrev}**\n\n"
|
||||
status_msg += f"**Moves:** {builder.move_count}\n"
|
||||
status_msg += f"**Status:** {'✅ Legal' if validation.is_legal else '❌ Illegal'}\n\n"
|
||||
|
||||
if validation.errors:
|
||||
status_msg += "**Errors:**\n"
|
||||
status_msg += "\n".join([f"• {error}" for error in validation.errors])
|
||||
status_msg += "\n\n"
|
||||
|
||||
if validation.suggestions:
|
||||
status_msg += "**Suggestions:**\n"
|
||||
status_msg += "\n".join([f"💡 {suggestion}" for suggestion in validation.suggestions])
|
||||
|
||||
await interaction.followup.send(status_msg, ephemeral=True)
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
"""Setup function for the cog."""
|
||||
await bot.add_cog(DropAddCommands(bot))
|
||||
369
commands/transactions/management.py
Normal file
369
commands/transactions/management.py
Normal file
@ -0,0 +1,369 @@
|
||||
"""
|
||||
Transaction Management Commands
|
||||
|
||||
Core transaction commands for roster management and transaction tracking.
|
||||
"""
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
|
||||
from utils.logging import get_contextual_logger
|
||||
from utils.decorators import logged_command
|
||||
from views.embeds import EmbedColors, EmbedTemplate
|
||||
from constants import SBA_CURRENT_SEASON
|
||||
|
||||
from services.transaction_service import transaction_service
|
||||
from services.roster_service import roster_service
|
||||
from services.team_service import team_service
|
||||
# No longer need TransactionStatus enum
|
||||
|
||||
|
||||
class TransactionCommands(commands.Cog):
|
||||
"""Transaction command handlers for roster management."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.logger = get_contextual_logger(f'{__name__}.TransactionCommands')
|
||||
|
||||
@app_commands.command(
|
||||
name="mymoves",
|
||||
description="View your pending and scheduled transactions"
|
||||
)
|
||||
@app_commands.describe(
|
||||
show_cancelled="Include cancelled transactions in the display (default: False)"
|
||||
)
|
||||
@logged_command("/mymoves")
|
||||
async def my_moves(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
show_cancelled: bool = False
|
||||
):
|
||||
"""Display user's transaction status and history."""
|
||||
await interaction.response.defer()
|
||||
|
||||
# Get user's team
|
||||
user_teams = await team_service.get_teams_by_owner(interaction.user.id, SBA_CURRENT_SEASON)
|
||||
|
||||
if not user_teams:
|
||||
await interaction.followup.send(
|
||||
"❌ You don't appear to own a team in the current season.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
team = user_teams[0] # Use first team if multiple
|
||||
|
||||
# Get transactions in parallel
|
||||
pending_task = transaction_service.get_pending_transactions(team.abbrev, SBA_CURRENT_SEASON)
|
||||
frozen_task = transaction_service.get_frozen_transactions(team.abbrev, SBA_CURRENT_SEASON)
|
||||
processed_task = transaction_service.get_processed_transactions(team.abbrev, SBA_CURRENT_SEASON)
|
||||
|
||||
pending_transactions = await pending_task
|
||||
frozen_transactions = await frozen_task
|
||||
processed_transactions = await processed_task
|
||||
|
||||
# Get cancelled if requested
|
||||
cancelled_transactions = []
|
||||
if show_cancelled:
|
||||
cancelled_transactions = await transaction_service.get_team_transactions(
|
||||
team.abbrev,
|
||||
SBA_CURRENT_SEASON,
|
||||
cancelled=True
|
||||
)
|
||||
|
||||
embed = await self._create_my_moves_embed(
|
||||
team,
|
||||
pending_transactions,
|
||||
frozen_transactions,
|
||||
processed_transactions,
|
||||
cancelled_transactions
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
@app_commands.command(
|
||||
name="legal",
|
||||
description="Check roster legality for current and next week"
|
||||
)
|
||||
@app_commands.describe(
|
||||
team="Team abbreviation to check (defaults to your team)"
|
||||
)
|
||||
@logged_command("/legal")
|
||||
async def legal(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
team: Optional[str] = None
|
||||
):
|
||||
"""Check roster legality and display detailed validation results."""
|
||||
await interaction.response.defer()
|
||||
|
||||
# Get target team
|
||||
if team:
|
||||
target_team = await team_service.get_team_by_abbrev(team.upper(), SBA_CURRENT_SEASON)
|
||||
if not target_team:
|
||||
await interaction.followup.send(
|
||||
f"❌ Could not find team '{team}' in season {SBA_CURRENT_SEASON}.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Get user's team
|
||||
user_teams = await team_service.get_teams_by_owner(interaction.user.id, SBA_CURRENT_SEASON)
|
||||
if not user_teams:
|
||||
await interaction.followup.send(
|
||||
"❌ You don't appear to own a team. Please specify a team abbreviation.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
target_team = user_teams[0]
|
||||
|
||||
# Get rosters in parallel
|
||||
current_roster, next_roster = await asyncio.gather(
|
||||
roster_service.get_current_roster(target_team.id),
|
||||
roster_service.get_next_roster(target_team.id)
|
||||
)
|
||||
|
||||
if not current_roster and not next_roster:
|
||||
await interaction.followup.send(
|
||||
f"❌ Could not retrieve roster data for {target_team.abbrev}.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Validate rosters in parallel
|
||||
validation_tasks = []
|
||||
if current_roster:
|
||||
validation_tasks.append(roster_service.validate_roster(current_roster))
|
||||
else:
|
||||
validation_tasks.append(asyncio.create_task(asyncio.sleep(0))) # Dummy task
|
||||
|
||||
if next_roster:
|
||||
validation_tasks.append(roster_service.validate_roster(next_roster))
|
||||
else:
|
||||
validation_tasks.append(asyncio.create_task(asyncio.sleep(0))) # Dummy task
|
||||
|
||||
validation_results = await asyncio.gather(*validation_tasks)
|
||||
current_validation = validation_results[0] if current_roster else None
|
||||
next_validation = validation_results[1] if next_roster else None
|
||||
|
||||
embed = await self._create_legal_embed(
|
||||
target_team,
|
||||
current_roster,
|
||||
next_roster,
|
||||
current_validation,
|
||||
next_validation
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
async def _create_my_moves_embed(
|
||||
self,
|
||||
team,
|
||||
pending_transactions,
|
||||
frozen_transactions,
|
||||
processed_transactions,
|
||||
cancelled_transactions
|
||||
) -> discord.Embed:
|
||||
"""Create embed showing user's transaction status."""
|
||||
|
||||
embed = EmbedTemplate.create_base_embed(
|
||||
title=f"📋 Transaction Status - {team.abbrev}",
|
||||
description=f"{team.lname} • Season {SBA_CURRENT_SEASON}",
|
||||
color=EmbedColors.INFO
|
||||
)
|
||||
|
||||
# Add team thumbnail if available
|
||||
if hasattr(team, 'thumbnail') and team.thumbnail:
|
||||
embed.set_thumbnail(url=team.thumbnail)
|
||||
|
||||
# Pending transactions
|
||||
if pending_transactions:
|
||||
pending_lines = []
|
||||
for transaction in pending_transactions[-5:]: # Show last 5
|
||||
pending_lines.append(
|
||||
f"{transaction.status_emoji} Week {transaction.week}: {transaction.move_description}"
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="⏳ Pending Transactions",
|
||||
value="\n".join(pending_lines),
|
||||
inline=False
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="⏳ Pending Transactions",
|
||||
value="No pending transactions",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Frozen transactions (scheduled for processing)
|
||||
if frozen_transactions:
|
||||
frozen_lines = []
|
||||
for transaction in frozen_transactions[-3:]: # Show last 3
|
||||
frozen_lines.append(
|
||||
f"{transaction.status_emoji} Week {transaction.week}: {transaction.move_description}"
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="❄️ Scheduled for Processing",
|
||||
value="\n".join(frozen_lines),
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Recent processed transactions
|
||||
if processed_transactions:
|
||||
processed_lines = []
|
||||
for transaction in processed_transactions[-3:]: # Show last 3
|
||||
processed_lines.append(
|
||||
f"{transaction.status_emoji} Week {transaction.week}: {transaction.move_description}"
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="✅ Recently Processed",
|
||||
value="\n".join(processed_lines),
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Cancelled transactions (if requested)
|
||||
if cancelled_transactions:
|
||||
cancelled_lines = []
|
||||
for transaction in cancelled_transactions[-2:]: # Show last 2
|
||||
cancelled_lines.append(
|
||||
f"{transaction.status_emoji} Week {transaction.week}: {transaction.move_description}"
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="❌ Cancelled Transactions",
|
||||
value="\n".join(cancelled_lines),
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Transaction summary
|
||||
total_pending = len(pending_transactions)
|
||||
total_frozen = len(frozen_transactions)
|
||||
|
||||
status_text = []
|
||||
if total_pending > 0:
|
||||
status_text.append(f"{total_pending} pending")
|
||||
if total_frozen > 0:
|
||||
status_text.append(f"{total_frozen} scheduled")
|
||||
|
||||
embed.add_field(
|
||||
name="Summary",
|
||||
value=", ".join(status_text) if status_text else "No active transactions",
|
||||
inline=True
|
||||
)
|
||||
|
||||
embed.set_footer(text="Use /legal to check roster legality")
|
||||
return embed
|
||||
|
||||
async def _create_legal_embed(
|
||||
self,
|
||||
team,
|
||||
current_roster,
|
||||
next_roster,
|
||||
current_validation,
|
||||
next_validation
|
||||
) -> discord.Embed:
|
||||
"""Create embed showing roster legality check results."""
|
||||
|
||||
# Determine overall status
|
||||
overall_legal = True
|
||||
if current_validation and not current_validation.is_legal:
|
||||
overall_legal = False
|
||||
if next_validation and not next_validation.is_legal:
|
||||
overall_legal = False
|
||||
|
||||
status_emoji = "✅" if overall_legal else "❌"
|
||||
embed_color = EmbedColors.SUCCESS if overall_legal else EmbedColors.ERROR
|
||||
|
||||
embed = EmbedTemplate.create_base_embed(
|
||||
title=f"{status_emoji} Roster Check - {team.abbrev}",
|
||||
description=f"{team.lname} • Season {SBA_CURRENT_SEASON}",
|
||||
color=embed_color
|
||||
)
|
||||
|
||||
# Add team thumbnail if available
|
||||
if hasattr(team, 'thumbnail') and team.thumbnail:
|
||||
embed.set_thumbnail(url=team.thumbnail)
|
||||
|
||||
# Current week roster
|
||||
if current_roster and current_validation:
|
||||
current_lines = []
|
||||
current_lines.append(f"**Players:** {current_validation.active_players} active, {current_validation.il_players} IL")
|
||||
current_lines.append(f"**sWAR:** {current_validation.total_sWAR:.1f}")
|
||||
|
||||
if current_validation.errors:
|
||||
current_lines.append(f"**❌ Errors:** {len(current_validation.errors)}")
|
||||
for error in current_validation.errors[:3]: # Show first 3 errors
|
||||
current_lines.append(f"• {error}")
|
||||
|
||||
if current_validation.warnings:
|
||||
current_lines.append(f"**⚠️ Warnings:** {len(current_validation.warnings)}")
|
||||
for warning in current_validation.warnings[:2]: # Show first 2 warnings
|
||||
current_lines.append(f"• {warning}")
|
||||
|
||||
embed.add_field(
|
||||
name=f"{current_validation.status_emoji} Current Week",
|
||||
value="\n".join(current_lines),
|
||||
inline=True
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="❓ Current Week",
|
||||
value="Roster data not available",
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Next week roster
|
||||
if next_roster and next_validation:
|
||||
next_lines = []
|
||||
next_lines.append(f"**Players:** {next_validation.active_players} active, {next_validation.il_players} IL")
|
||||
next_lines.append(f"**sWAR:** {next_validation.total_sWAR:.1f}")
|
||||
|
||||
if next_validation.errors:
|
||||
next_lines.append(f"**❌ Errors:** {len(next_validation.errors)}")
|
||||
for error in next_validation.errors[:3]: # Show first 3 errors
|
||||
next_lines.append(f"• {error}")
|
||||
|
||||
if next_validation.warnings:
|
||||
next_lines.append(f"**⚠️ Warnings:** {len(next_validation.warnings)}")
|
||||
for warning in next_validation.warnings[:2]: # Show first 2 warnings
|
||||
next_lines.append(f"• {warning}")
|
||||
|
||||
embed.add_field(
|
||||
name=f"{next_validation.status_emoji} Next Week",
|
||||
value="\n".join(next_lines),
|
||||
inline=True
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="❓ Next Week",
|
||||
value="Roster data not available",
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Overall status
|
||||
if overall_legal:
|
||||
embed.add_field(
|
||||
name="Overall Status",
|
||||
value="✅ All rosters are legal",
|
||||
inline=False
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="Overall Status",
|
||||
value="❌ Roster violations found - please review and correct",
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.set_footer(text="Roster validation based on current league rules")
|
||||
return embed
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
"""Load the transaction commands cog."""
|
||||
await bot.add_cog(TransactionCommands(bot))
|
||||
@ -82,20 +82,29 @@ class Player(SBABaseModel):
|
||||
# Make a copy to avoid modifying original data
|
||||
player_data = data.copy()
|
||||
|
||||
# Handle nested team structure
|
||||
if 'team' in player_data and isinstance(player_data['team'], dict):
|
||||
team_data = player_data['team']
|
||||
# Extract team_id from nested team object
|
||||
player_data['team_id'] = team_data.get('id')
|
||||
# Keep team object for optional population
|
||||
if team_data.get('id'):
|
||||
from models.team import Team
|
||||
player_data['team'] = Team.from_api_data(team_data)
|
||||
# Handle team structure - can be nested object or just ID
|
||||
if 'team' in player_data:
|
||||
if isinstance(player_data['team'], dict):
|
||||
# Nested team object from regular endpoints
|
||||
team_data = player_data['team']
|
||||
player_data['team_id'] = team_data.get('id')
|
||||
if team_data.get('id'):
|
||||
from models.team import Team
|
||||
player_data['team'] = Team.from_api_data(team_data)
|
||||
elif isinstance(player_data['team'], int):
|
||||
# Team ID only from search endpoints
|
||||
player_data['team_id'] = player_data['team']
|
||||
player_data['team'] = None # No nested team object available
|
||||
|
||||
# Handle sbaplayer structure (convert to SBAPlayer model)
|
||||
if 'sbaplayer' in player_data and isinstance(player_data['sbaplayer'], dict):
|
||||
sba_data = player_data['sbaplayer']
|
||||
player_data['sbaplayer'] = SBAPlayer.from_api_data(sba_data)
|
||||
# Handle sbaplayer structure - can be nested object or just ID
|
||||
if 'sbaplayer' in player_data:
|
||||
if isinstance(player_data['sbaplayer'], dict):
|
||||
# Nested sbaplayer object
|
||||
sba_data = player_data['sbaplayer']
|
||||
player_data['sbaplayer'] = SBAPlayer.from_api_data(sba_data)
|
||||
elif isinstance(player_data['sbaplayer'], int):
|
||||
# SBA player ID only from search endpoints
|
||||
player_data['sbaplayer'] = None # No nested object available
|
||||
|
||||
return super().from_api_data(player_data)
|
||||
|
||||
|
||||
148
models/roster.py
Normal file
148
models/roster.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""
|
||||
Roster models for SBA team roster management
|
||||
|
||||
Represents team rosters and roster-related data.
|
||||
"""
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pydantic import Field
|
||||
|
||||
from models.base import SBABaseModel
|
||||
from models.player import Player
|
||||
|
||||
|
||||
class RosterPlayer(SBABaseModel):
|
||||
"""Represents a player on a team roster."""
|
||||
|
||||
player_id: int = Field(..., description="Player ID from database")
|
||||
player_name: str = Field(..., description="Player name")
|
||||
position: str = Field(..., description="Primary position")
|
||||
wara: float = Field(..., description="Player WARA value")
|
||||
status: str = Field(default="active", description="Player status (active, il, minor)")
|
||||
|
||||
# Optional player details
|
||||
injury_status: Optional[str] = Field(None, description="Injury status if applicable")
|
||||
contract_info: Optional[Dict[str, Any]] = Field(None, description="Contract information")
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Check if player is on active roster."""
|
||||
return self.status == "active"
|
||||
|
||||
@property
|
||||
def is_injured(self) -> bool:
|
||||
"""Check if player is on injured list."""
|
||||
return self.status == "il"
|
||||
|
||||
@property
|
||||
def is_minor_league(self) -> bool:
|
||||
"""Check if player is in minor leagues."""
|
||||
return self.status == "minor"
|
||||
|
||||
@property
|
||||
def status_emoji(self) -> str:
|
||||
"""Emoji representation of player status."""
|
||||
status_emojis = {
|
||||
"active": "⚾",
|
||||
"il": "🏥",
|
||||
"minor": "🏗️",
|
||||
"suspended": "⛔"
|
||||
}
|
||||
return status_emojis.get(self.status, "❓")
|
||||
|
||||
|
||||
class TeamRoster(SBABaseModel):
|
||||
"""Represents a complete team roster for a specific week."""
|
||||
|
||||
team_id: int = Field(..., description="Team ID from database")
|
||||
team_abbrev: str = Field(..., description="Team abbreviation")
|
||||
season: int = Field(..., description="Season number")
|
||||
week: int = Field(..., description="Week number")
|
||||
|
||||
# Roster sections
|
||||
active_players: List[RosterPlayer] = Field(default_factory=list, description="Active roster players")
|
||||
il_players: List[RosterPlayer] = Field(default_factory=list, description="Injured list players")
|
||||
minor_league_players: List[RosterPlayer] = Field(default_factory=list, description="Minor league players")
|
||||
|
||||
# Roster statistics
|
||||
total_wara: float = Field(default=0.0, description="Total active roster WARA")
|
||||
salary_total: Optional[float] = Field(None, description="Total salary if applicable")
|
||||
|
||||
@property
|
||||
def all_players(self) -> List[RosterPlayer]:
|
||||
"""All players on the roster regardless of status."""
|
||||
return self.active_players + self.il_players + self.minor_league_players
|
||||
|
||||
@property
|
||||
def total_players(self) -> int:
|
||||
"""Total number of players on roster."""
|
||||
return len(self.all_players)
|
||||
|
||||
@property
|
||||
def active_count(self) -> int:
|
||||
"""Number of active players."""
|
||||
return len(self.active_players)
|
||||
|
||||
@property
|
||||
def il_count(self) -> int:
|
||||
"""Number of players on IL."""
|
||||
return len(self.il_players)
|
||||
|
||||
@property
|
||||
def minor_league_count(self) -> int:
|
||||
"""Number of minor league players."""
|
||||
return len(self.minor_league_players)
|
||||
|
||||
def get_players_by_position(self, position: str) -> List[RosterPlayer]:
|
||||
"""Get all active players at a specific position."""
|
||||
return [p for p in self.active_players if p.position == position]
|
||||
|
||||
def find_player(self, player_name: str) -> Optional[RosterPlayer]:
|
||||
"""Find a player by name on the roster."""
|
||||
for player in self.all_players:
|
||||
if player.player_name.lower() == player_name.lower():
|
||||
return player
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_api_data(cls, data: dict) -> 'TeamRoster':
|
||||
"""
|
||||
Create TeamRoster instance from API data.
|
||||
|
||||
Expected format from API:
|
||||
{
|
||||
'team_id': 123,
|
||||
'team_abbrev': 'NYY',
|
||||
'season': 12,
|
||||
'week': 5,
|
||||
'active': {'players': [...], 'WARa': 45.2},
|
||||
'il': {'players': [...], 'WARa': 2.1},
|
||||
'minor': {'players': [...], 'WARa': 12.5}
|
||||
}
|
||||
"""
|
||||
roster_data = data.copy()
|
||||
|
||||
# Convert player sections
|
||||
for section, status in [('active', 'active'), ('il', 'il'), ('minor', 'minor')]:
|
||||
if section in data and isinstance(data[section], dict):
|
||||
players_data = data[section].get('players', [])
|
||||
players = []
|
||||
for player_data in players_data:
|
||||
player = RosterPlayer(
|
||||
player_id=player_data.get('id', 0),
|
||||
player_name=player_data.get('name', ''),
|
||||
position=player_data.get('pos_1', 'UNKNOWN'),
|
||||
wara=player_data.get('wara', 0.0),
|
||||
status=status
|
||||
)
|
||||
players.append(player)
|
||||
roster_data[f'{section}_players'] = players
|
||||
|
||||
# Remove original section
|
||||
if section in roster_data:
|
||||
del roster_data[section]
|
||||
|
||||
# Handle WARA totals
|
||||
if 'active' in data and isinstance(data['active'], dict):
|
||||
roster_data['total_wara'] = data['active'].get('WARa', 0.0)
|
||||
|
||||
return super().from_api_data(roster_data)
|
||||
@ -4,12 +4,21 @@ Team model for SBA teams
|
||||
Represents a team in the league with all associated metadata.
|
||||
"""
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
from pydantic import Field
|
||||
|
||||
from models.base import SBABaseModel
|
||||
from models.division import Division
|
||||
|
||||
|
||||
class RosterType(Enum):
|
||||
"""Roster designation types."""
|
||||
MAJOR_LEAGUE = "ml"
|
||||
MINOR_LEAGUE = "mil"
|
||||
INJURED_LIST = "il"
|
||||
FREE_AGENCY = "fa"
|
||||
|
||||
|
||||
class Team(SBABaseModel):
|
||||
"""Team model representing an SBA team."""
|
||||
|
||||
@ -57,5 +66,32 @@ class Team(SBABaseModel):
|
||||
|
||||
return super().from_api_data(team_data)
|
||||
|
||||
def roster_type(self) -> RosterType:
|
||||
"""Determine the roster type based on team abbreviation."""
|
||||
if len(self.abbrev) <= 3:
|
||||
return RosterType.MAJOR_LEAGUE
|
||||
|
||||
# For teams with extended abbreviations, check suffix patterns
|
||||
abbrev_lower = self.abbrev.lower()
|
||||
|
||||
# Pattern analysis:
|
||||
# - Minor League: ends with 'mil' (e.g., NYYMIL, BHMMIL)
|
||||
# - Injured List: ends with 'il' but not 'mil' (e.g., NYYIL, BOSIL)
|
||||
# - Edge case: teams whose base abbrev ends in 'M' + 'IL' = 'MIL'
|
||||
# Only applies if removing 'IL' gives us exactly a 3-char base team
|
||||
|
||||
if abbrev_lower.endswith('mil'):
|
||||
# Check if this is actually [BaseTeam]IL where BaseTeam ends in 'M'
|
||||
# E.g., BHMIL = BHM + IL (injured list), not minor league
|
||||
if len(self.abbrev) == 5: # Exactly 5 chars: 3-char base + IL
|
||||
potential_base = self.abbrev[:-2] # Remove 'IL'
|
||||
if len(potential_base) == 3 and potential_base.upper().endswith('M'):
|
||||
return RosterType.INJURED_LIST
|
||||
return RosterType.MINOR_LEAGUE
|
||||
elif abbrev_lower.endswith('il'):
|
||||
return RosterType.INJURED_LIST
|
||||
else:
|
||||
return RosterType.MAJOR_LEAGUE
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.abbrev} - {self.lname}"
|
||||
127
models/transaction.py
Normal file
127
models/transaction.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""
|
||||
Transaction models for SBA transaction management
|
||||
|
||||
Represents transactions and player moves based on actual API structure.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from pydantic import Field
|
||||
|
||||
from models.base import SBABaseModel
|
||||
from models.player import Player
|
||||
from models.team import Team
|
||||
|
||||
|
||||
class Transaction(SBABaseModel):
|
||||
"""
|
||||
Represents a single player transaction (move).
|
||||
|
||||
Based on actual API response structure:
|
||||
{
|
||||
"id": 27787,
|
||||
"week": 10,
|
||||
"player": { ... },
|
||||
"oldteam": { ... },
|
||||
"newteam": { ... },
|
||||
"season": 12,
|
||||
"moveid": "Season-012-Week-10-19-13:04:41",
|
||||
"cancelled": false,
|
||||
"frozen": false
|
||||
}
|
||||
"""
|
||||
|
||||
# Core transaction fields
|
||||
id: int = Field(..., description="Transaction ID")
|
||||
week: int = Field(..., description="Week this transaction is for")
|
||||
season: int = Field(..., description="Season number")
|
||||
moveid: str = Field(..., description="Unique move identifier string")
|
||||
|
||||
# Player and team information
|
||||
player: Player = Field(..., description="Player being moved")
|
||||
oldteam: Team = Field(..., description="Team player is leaving")
|
||||
newteam: Team = Field(..., description="Team player is joining")
|
||||
|
||||
# Transaction status
|
||||
cancelled: bool = Field(default=False, description="Whether transaction is cancelled")
|
||||
frozen: bool = Field(default=False, description="Whether transaction is frozen")
|
||||
|
||||
@property
|
||||
def is_cancelled(self) -> bool:
|
||||
"""Check if transaction is cancelled."""
|
||||
return self.cancelled
|
||||
|
||||
@property
|
||||
def is_frozen(self) -> bool:
|
||||
"""Check if transaction is frozen (scheduled for processing)."""
|
||||
return self.frozen
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
"""Check if transaction is pending (not frozen, not cancelled)."""
|
||||
return not self.frozen and not self.cancelled
|
||||
|
||||
@property
|
||||
def status_emoji(self) -> str:
|
||||
"""Emoji representation of transaction status."""
|
||||
if self.cancelled:
|
||||
return "❌"
|
||||
elif self.frozen:
|
||||
return "❄️"
|
||||
else:
|
||||
return "⏳"
|
||||
|
||||
@property
|
||||
def status_text(self) -> str:
|
||||
"""Human readable status."""
|
||||
if self.cancelled:
|
||||
return "Cancelled"
|
||||
elif self.frozen:
|
||||
return "Frozen"
|
||||
else:
|
||||
return "Pending"
|
||||
|
||||
@property
|
||||
def move_description(self) -> str:
|
||||
"""Human readable description of the move."""
|
||||
return f"{self.player.name}: {self.oldteam.abbrev} → {self.newteam.abbrev}"
|
||||
|
||||
@property
|
||||
def is_major_league_move(self) -> bool:
|
||||
"""Check if this move involves major league rosters."""
|
||||
# Major league if neither team ends with 'MiL' and not FA
|
||||
from_is_major = self.oldteam.abbrev != 'FA' and not self.oldteam.abbrev.endswith('MiL')
|
||||
to_is_major = self.newteam.abbrev != 'FA' and not self.newteam.abbrev.endswith('MiL')
|
||||
return from_is_major or to_is_major
|
||||
|
||||
def __str__(self):
|
||||
return f"📋 Week {self.week}: {self.move_description} - {self.status_emoji} {self.status_text}"
|
||||
|
||||
|
||||
class RosterValidation(SBABaseModel):
|
||||
"""Results of roster legality validation."""
|
||||
|
||||
is_legal: bool = Field(..., description="Whether the roster is legal")
|
||||
errors: List[str] = Field(default_factory=list, description="List of validation errors")
|
||||
warnings: List[str] = Field(default_factory=list, description="List of validation warnings")
|
||||
|
||||
# Roster statistics
|
||||
total_players: int = Field(default=0, description="Total players on roster")
|
||||
active_players: int = Field(default=0, description="Active players")
|
||||
il_players: int = Field(default=0, description="Players on IL")
|
||||
minor_league_players: int = Field(default=0, description="Minor league players")
|
||||
|
||||
total_sWAR: float = Field(default=0.0, description="Total team sWAR")
|
||||
|
||||
@property
|
||||
def has_issues(self) -> bool:
|
||||
"""Whether there are any errors or warnings."""
|
||||
return len(self.errors) > 0 or len(self.warnings) > 0
|
||||
|
||||
@property
|
||||
def status_emoji(self) -> str:
|
||||
"""Emoji representation of validation status."""
|
||||
if not self.is_legal:
|
||||
return "❌"
|
||||
elif self.warnings:
|
||||
return "⚠️"
|
||||
else:
|
||||
return "✅"
|
||||
@ -133,6 +133,41 @@ class PlayerService(BaseService[Player]):
|
||||
logger.error(f"Error finding exact player match for '{name}': {e}")
|
||||
return None
|
||||
|
||||
async def search_players(self, query: str, limit: int = 10, season: Optional[int] = None) -> List[Player]:
|
||||
"""
|
||||
Search for players using the dedicated /v3/players/search endpoint.
|
||||
|
||||
Args:
|
||||
query: Search query for player name
|
||||
limit: Maximum number of results to return (1-50)
|
||||
season: Season to search in (defaults to current season)
|
||||
|
||||
Returns:
|
||||
List of matching players (up to limit)
|
||||
"""
|
||||
try:
|
||||
params = [('q', query), ('limit', str(limit))]
|
||||
if season is not None:
|
||||
params.append(('season', str(season)))
|
||||
|
||||
client = await self.get_client()
|
||||
data = await client.get('players/search', params=params)
|
||||
|
||||
if not data:
|
||||
logger.debug(f"No players found for search query '{query}'")
|
||||
return []
|
||||
|
||||
# Handle API response format: {'count': int, 'players': [...]}
|
||||
items, count = self._extract_items_and_count_from_response(data)
|
||||
players = [self.model_class.from_api_data(item) for item in items]
|
||||
|
||||
logger.debug(f"Search '{query}' returned {len(players)} of {count} matches")
|
||||
return players
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in player search for '{query}': {e}")
|
||||
return []
|
||||
|
||||
async def search_players_fuzzy(self, query: str, limit: int = 10, season: Optional[int] = None) -> List[Player]:
|
||||
"""
|
||||
Fuzzy search for players by name with limit using existing name search functionality.
|
||||
|
||||
191
services/roster_service.py
Normal file
191
services/roster_service.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""
|
||||
Roster service for Discord Bot v2.0
|
||||
|
||||
Handles roster operations and validation.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from services.base_service import BaseService
|
||||
from models.roster import TeamRoster, RosterPlayer
|
||||
from models.transaction import RosterValidation
|
||||
from exceptions import APIException
|
||||
|
||||
logger = logging.getLogger(f'{__name__}.RosterService')
|
||||
|
||||
|
||||
class RosterService:
|
||||
"""Service for roster operations and validation."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize roster service."""
|
||||
from api.client import get_global_client
|
||||
self._get_client = get_global_client
|
||||
logger.debug("RosterService initialized")
|
||||
|
||||
async def get_client(self):
|
||||
"""Get the API client."""
|
||||
return await self._get_client()
|
||||
|
||||
async def get_team_roster(
|
||||
self,
|
||||
team_id: int,
|
||||
week_type: str = "current"
|
||||
) -> Optional[TeamRoster]:
|
||||
"""
|
||||
Get team roster for current or next week.
|
||||
|
||||
Args:
|
||||
team_id: Team ID from database
|
||||
week_type: "current" or "next"
|
||||
|
||||
Returns:
|
||||
TeamRoster object or None if not found
|
||||
"""
|
||||
try:
|
||||
client = await self.get_client()
|
||||
|
||||
# Use the team roster endpoint
|
||||
roster_data = await client.get(f'teams/{team_id}/roster/{week_type}')
|
||||
|
||||
if not roster_data:
|
||||
logger.warning(f"No roster data found for team {team_id}, week {week_type}")
|
||||
return None
|
||||
|
||||
# Add team metadata if not present
|
||||
if 'team_id' not in roster_data:
|
||||
roster_data['team_id'] = team_id
|
||||
|
||||
# Determine week number (this might need adjustment based on API)
|
||||
roster_data.setdefault('week', 0) # Will need current week info
|
||||
roster_data.setdefault('season', 12) # Will need current season info
|
||||
|
||||
roster = TeamRoster.from_api_data(roster_data)
|
||||
|
||||
logger.debug(f"Retrieved roster for team {team_id}, {week_type} week")
|
||||
return roster
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting roster for team {team_id}: {e}")
|
||||
raise APIException(f"Failed to retrieve roster: {e}")
|
||||
|
||||
async def get_current_roster(self, team_id: int) -> Optional[TeamRoster]:
|
||||
"""Get current week roster."""
|
||||
return await self.get_team_roster(team_id, "current")
|
||||
|
||||
async def get_next_roster(self, team_id: int) -> Optional[TeamRoster]:
|
||||
"""Get next week roster."""
|
||||
return await self.get_team_roster(team_id, "next")
|
||||
|
||||
async def validate_roster(self, roster: TeamRoster) -> RosterValidation:
|
||||
"""
|
||||
Validate roster for legality according to league rules.
|
||||
|
||||
Args:
|
||||
roster: TeamRoster to validate
|
||||
|
||||
Returns:
|
||||
RosterValidation with results
|
||||
"""
|
||||
try:
|
||||
validation = RosterValidation(
|
||||
is_legal=True,
|
||||
total_players=roster.total_players,
|
||||
active_players=roster.active_count,
|
||||
il_players=roster.il_count,
|
||||
minor_league_players=roster.minor_league_count,
|
||||
total_wara=roster.total_wara
|
||||
)
|
||||
|
||||
# Validate active roster size (typical limits)
|
||||
if roster.active_count > 25: # Adjust based on league rules
|
||||
validation.is_legal = False
|
||||
validation.errors.append(f"Too many active players: {roster.active_count}/25")
|
||||
elif roster.active_count < 20: # Minimum active roster
|
||||
validation.warnings.append(f"Low active player count: {roster.active_count}")
|
||||
|
||||
# Validate total roster size
|
||||
if roster.total_players > 50: # Adjust based on league rules
|
||||
validation.is_legal = False
|
||||
validation.errors.append(f"Total roster too large: {roster.total_players}/50")
|
||||
|
||||
# Position requirements validation
|
||||
position_counts = self._count_positions(roster.active_players)
|
||||
|
||||
# Check catcher requirement (at least 2 catchers)
|
||||
if position_counts.get('C', 0) < 2:
|
||||
validation.warnings.append("Fewer than 2 catchers on active roster")
|
||||
|
||||
# Check pitcher requirements (at least 10 pitchers)
|
||||
pitcher_count = position_counts.get('SP', 0) + position_counts.get('RP', 0) + position_counts.get('P', 0)
|
||||
if pitcher_count < 10:
|
||||
validation.warnings.append(f"Fewer than 10 pitchers on active roster: {pitcher_count}")
|
||||
|
||||
# WARA validation (if there are limits)
|
||||
if validation.total_wara > 100: # Adjust based on league rules
|
||||
validation.warnings.append(f"High WARA total: {validation.total_wara:.1f}")
|
||||
elif validation.total_wara < 20:
|
||||
validation.warnings.append(f"Low WARA total: {validation.total_wara:.1f}")
|
||||
|
||||
logger.debug(f"Validated roster: legal={validation.is_legal}, {len(validation.errors)} errors, {len(validation.warnings)} warnings")
|
||||
return validation
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating roster: {e}")
|
||||
return RosterValidation(
|
||||
is_legal=False,
|
||||
errors=[f"Validation error: {str(e)}"]
|
||||
)
|
||||
|
||||
def _count_positions(self, players: List[RosterPlayer]) -> Dict[str, int]:
|
||||
"""Count players by position."""
|
||||
position_counts = {}
|
||||
for player in players:
|
||||
pos = player.position
|
||||
position_counts[pos] = position_counts.get(pos, 0) + 1
|
||||
return position_counts
|
||||
|
||||
async def get_roster_summary(self, roster: TeamRoster) -> Dict[str, any]:
|
||||
"""
|
||||
Get a summary of roster composition.
|
||||
|
||||
Args:
|
||||
roster: TeamRoster to summarize
|
||||
|
||||
Returns:
|
||||
Dictionary with roster summary information
|
||||
"""
|
||||
try:
|
||||
position_counts = self._count_positions(roster.active_players)
|
||||
|
||||
# Group positions
|
||||
catchers = position_counts.get('C', 0)
|
||||
infielders = sum(position_counts.get(pos, 0) for pos in ['1B', '2B', '3B', 'SS', 'IF'])
|
||||
outfielders = sum(position_counts.get(pos, 0) for pos in ['LF', 'CF', 'RF', 'OF'])
|
||||
pitchers = sum(position_counts.get(pos, 0) for pos in ['SP', 'RP', 'P'])
|
||||
dh = position_counts.get('DH', 0)
|
||||
|
||||
summary = {
|
||||
'total_active': roster.active_count,
|
||||
'total_il': roster.il_count,
|
||||
'total_minor': roster.minor_league_count,
|
||||
'total_wara': roster.total_wara,
|
||||
'positions': {
|
||||
'catchers': catchers,
|
||||
'infielders': infielders,
|
||||
'outfielders': outfielders,
|
||||
'pitchers': pitchers,
|
||||
'dh': dh
|
||||
},
|
||||
'detailed_positions': position_counts
|
||||
}
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating roster summary: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# Global service instance
|
||||
roster_service = RosterService()
|
||||
@ -7,7 +7,7 @@ import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from services.base_service import BaseService
|
||||
from models.team import Team
|
||||
from models.team import Team, RosterType
|
||||
from constants import SBA_CURRENT_SEASON
|
||||
from exceptions import APIException
|
||||
|
||||
@ -51,6 +51,52 @@ class TeamService(BaseService[Team]):
|
||||
logger.error(f"Unexpected error getting team {team_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_teams_by_owner(
|
||||
self,
|
||||
owner_id: int,
|
||||
season: Optional[int] = None,
|
||||
roster_type: Optional[str] = None
|
||||
) -> List[Team]:
|
||||
"""
|
||||
Get teams owned by a specific Discord user.
|
||||
|
||||
Args:
|
||||
owner_id: Discord user ID
|
||||
season: Season number (defaults to current season)
|
||||
roster_type: Filter by roster type ('ml', 'mil', 'il') - optional
|
||||
|
||||
Returns:
|
||||
List of Team instances owned by the user, optionally filtered by type
|
||||
"""
|
||||
try:
|
||||
season = season or SBA_CURRENT_SEASON
|
||||
params = [
|
||||
('owner_id', str(owner_id)),
|
||||
('season', str(season))
|
||||
]
|
||||
|
||||
teams = await self.get_all_items(params=params)
|
||||
|
||||
# Filter by roster type if specified
|
||||
if roster_type and teams:
|
||||
try:
|
||||
target_type = RosterType(roster_type)
|
||||
teams = [team for team in teams if team.roster_type() == target_type]
|
||||
logger.debug(f"Filtered to {len(teams)} {roster_type} teams for owner {owner_id}")
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid roster_type '{roster_type}' - returning all teams")
|
||||
|
||||
if teams:
|
||||
logger.debug(f"Found {len(teams)} teams for owner {owner_id} in season {season}")
|
||||
return teams
|
||||
|
||||
logger.debug(f"No teams found for owner {owner_id} in season {season}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting teams for owner {owner_id}: {e}")
|
||||
return []
|
||||
|
||||
async def get_team_by_abbrev(self, abbrev: str, season: Optional[int] = None) -> Optional[Team]:
|
||||
"""
|
||||
Get team by abbreviation for a specific season.
|
||||
|
||||
406
services/transaction_builder.py
Normal file
406
services/transaction_builder.py
Normal file
@ -0,0 +1,406 @@
|
||||
"""
|
||||
Transaction Builder Service
|
||||
|
||||
Handles the complex logic for building multi-move transactions interactively.
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple, Set
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from models.transaction import Transaction
|
||||
from models.team import Team
|
||||
from models.player import Player
|
||||
from models.roster import TeamRoster
|
||||
from services.player_service import player_service
|
||||
from services.team_service import team_service
|
||||
from services.roster_service import roster_service
|
||||
from services.transaction_service import transaction_service
|
||||
from services.league_service import league_service
|
||||
from models.team import RosterType
|
||||
from constants import SBA_CURRENT_SEASON
|
||||
|
||||
logger = logging.getLogger(f'{__name__}.TransactionBuilder')
|
||||
|
||||
|
||||
# Removed MoveAction enum - using simple from/to roster locations instead
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransactionMove:
|
||||
"""Individual move within a transaction."""
|
||||
player: Player
|
||||
from_roster: RosterType
|
||||
to_roster: RosterType
|
||||
from_team: Optional[Team] = None
|
||||
to_team: Optional[Team] = None
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Human readable move description."""
|
||||
# Determine emoji and format based on from/to locations
|
||||
if self.from_roster == RosterType.FREE_AGENCY and self.to_roster != RosterType.FREE_AGENCY:
|
||||
# Add from Free Agency
|
||||
emoji = "➕"
|
||||
return f"{emoji} {self.player.name}: FA → {self.to_team.abbrev} ({self.to_roster.value.upper()})"
|
||||
elif self.from_roster != RosterType.FREE_AGENCY and self.to_roster == RosterType.FREE_AGENCY:
|
||||
# Drop to Free Agency
|
||||
emoji = "➖"
|
||||
return f"{emoji} {self.player.name}: {self.from_team.abbrev} ({self.from_roster.value.upper()}) → FA"
|
||||
elif self.from_roster == RosterType.MINOR_LEAGUE and self.to_roster == RosterType.MAJOR_LEAGUE:
|
||||
# Recall from MiL to ML
|
||||
emoji = "⬆️"
|
||||
return f"{emoji} {self.player.name}: {self.from_team.abbrev} (MiL) → {self.to_team.abbrev} (ML)"
|
||||
elif self.from_roster == RosterType.MAJOR_LEAGUE and self.to_roster == RosterType.MINOR_LEAGUE:
|
||||
# Demote from ML to MiL
|
||||
emoji = "⬇️"
|
||||
return f"{emoji} {self.player.name}: {self.from_team.abbrev} (ML) → {self.to_team.abbrev} (MiL)"
|
||||
elif self.to_roster == RosterType.INJURED_LIST:
|
||||
# Move to Injured List
|
||||
emoji = "🏥"
|
||||
from_desc = "FA" if self.from_roster == RosterType.FREE_AGENCY else f"{self.from_team.abbrev} ({self.from_roster.value.upper()})"
|
||||
return f"{emoji} {self.player.name}: {from_desc} → {self.to_team.abbrev} (IL)"
|
||||
elif self.from_roster == RosterType.INJURED_LIST:
|
||||
# Return from Injured List
|
||||
emoji = "💊"
|
||||
to_desc = "FA" if self.to_roster == RosterType.FREE_AGENCY else f"{self.to_team.abbrev} ({self.to_roster.value.upper()})"
|
||||
return f"{emoji} {self.player.name}: {self.from_team.abbrev} (IL) → {to_desc}"
|
||||
else:
|
||||
# Generic move
|
||||
emoji = "🔄"
|
||||
from_desc = "FA" if self.from_roster == RosterType.FREE_AGENCY else f"{self.from_team.abbrev} ({self.from_roster.value.upper()})"
|
||||
to_desc = "FA" if self.to_roster == RosterType.FREE_AGENCY else f"{self.to_team.abbrev} ({self.to_roster.value.upper()})"
|
||||
return f"{emoji} {self.player.name}: {from_desc} → {to_desc}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RosterValidationResult:
|
||||
"""Results of roster validation."""
|
||||
is_legal: bool
|
||||
major_league_count: int
|
||||
minor_league_count: int
|
||||
warnings: List[str]
|
||||
errors: List[str]
|
||||
suggestions: List[str]
|
||||
major_league_limit: int = 26
|
||||
minor_league_limit: int = 6
|
||||
|
||||
@property
|
||||
def major_league_status(self) -> str:
|
||||
"""Status string for major league roster."""
|
||||
if self.major_league_count > self.major_league_limit:
|
||||
return f"❌ Major League: {self.major_league_count}/{self.major_league_limit} (Over limit!)"
|
||||
elif self.major_league_count == self.major_league_limit:
|
||||
return f"✅ Major League: {self.major_league_count}/{self.major_league_limit} (Legal)"
|
||||
else:
|
||||
return f"✅ Major League: {self.major_league_count}/{self.major_league_limit} (Legal)"
|
||||
|
||||
@property
|
||||
def minor_league_status(self) -> str:
|
||||
"""Status string for minor league roster."""
|
||||
if self.minor_league_count > self.minor_league_limit:
|
||||
return f"❌ Minor League: {self.minor_league_count}/{self.minor_league_limit} (Over limit!)"
|
||||
elif self.minor_league_count == self.minor_league_limit:
|
||||
return f"✅ Minor League: {self.minor_league_count}/{self.minor_league_limit} (Legal)"
|
||||
else:
|
||||
return f"✅ Minor League: {self.minor_league_count}/{self.minor_league_limit} (Legal)"
|
||||
|
||||
|
||||
class TransactionBuilder:
|
||||
"""Interactive transaction builder for complex multi-move transactions."""
|
||||
|
||||
def __init__(self, team: Team, user_id: int, season: int = SBA_CURRENT_SEASON):
|
||||
"""
|
||||
Initialize transaction builder.
|
||||
|
||||
Args:
|
||||
team: Team making the transaction
|
||||
user_id: Discord user ID of the GM
|
||||
season: Season number
|
||||
"""
|
||||
self.team = team
|
||||
self.user_id = user_id
|
||||
self.season = season
|
||||
self.moves: List[TransactionMove] = []
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
|
||||
# Cache for roster data
|
||||
self._current_roster: Optional[TeamRoster] = None
|
||||
self._roster_loaded = False
|
||||
|
||||
logger.info(f"TransactionBuilder initialized for {team.abbrev} by user {user_id}")
|
||||
|
||||
async def load_roster_data(self) -> None:
|
||||
"""Load current roster data for the team."""
|
||||
if self._roster_loaded:
|
||||
return
|
||||
|
||||
try:
|
||||
self._current_roster = await roster_service.get_current_roster(self.team.id)
|
||||
self._roster_loaded = True
|
||||
logger.debug(f"Loaded roster data for team {self.team.abbrev}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load roster data: {e}")
|
||||
self._current_roster = None
|
||||
self._roster_loaded = True
|
||||
|
||||
def add_move(self, move: TransactionMove) -> tuple[bool, str]:
|
||||
"""
|
||||
Add a move to the transaction.
|
||||
|
||||
Args:
|
||||
move: TransactionMove to add
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, error_message: str). If success is True, error_message is empty.
|
||||
"""
|
||||
# Check if player is already in a move
|
||||
existing_move = self.get_move_for_player(move.player.id)
|
||||
if existing_move:
|
||||
error_msg = f"Player {move.player.name} already has a move in this transaction"
|
||||
logger.warning(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# Check if from_team and to_team are the same AND from_roster and to_roster are the same
|
||||
# (when both teams are not None) - this would be a meaningless move
|
||||
if (move.from_team is not None and move.to_team is not None and
|
||||
move.from_team.id == move.to_team.id and move.from_roster == move.to_roster):
|
||||
error_msg = f"Cannot move {move.player.name} from {move.from_team.abbrev} ({move.from_roster.value.upper()}) to {move.to_team.abbrev} ({move.to_roster.value.upper()}) - player is already in that location"
|
||||
logger.warning(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
self.moves.append(move)
|
||||
logger.info(f"Added move: {move.description}")
|
||||
return True, ""
|
||||
|
||||
def remove_move(self, player_id: int) -> bool:
|
||||
"""
|
||||
Remove a move for a specific player.
|
||||
|
||||
Args:
|
||||
player_id: ID of player to remove move for
|
||||
|
||||
Returns:
|
||||
True if move was removed
|
||||
"""
|
||||
original_count = len(self.moves)
|
||||
self.moves = [move for move in self.moves if move.player.id != player_id]
|
||||
|
||||
removed = len(self.moves) < original_count
|
||||
if removed:
|
||||
logger.info(f"Removed move for player {player_id}")
|
||||
|
||||
return removed
|
||||
|
||||
def get_move_for_player(self, player_id: int) -> Optional[TransactionMove]:
|
||||
"""Get the move for a specific player if it exists."""
|
||||
for move in self.moves:
|
||||
if move.player.id == player_id:
|
||||
return move
|
||||
return None
|
||||
|
||||
async def validate_transaction(self) -> RosterValidationResult:
|
||||
"""
|
||||
Validate the current transaction and return detailed results.
|
||||
|
||||
Returns:
|
||||
RosterValidationResult with validation details
|
||||
"""
|
||||
await self.load_roster_data()
|
||||
|
||||
if not self._current_roster:
|
||||
return RosterValidationResult(
|
||||
is_legal=False,
|
||||
major_league_count=0,
|
||||
minor_league_count=0,
|
||||
warnings=[],
|
||||
errors=["Could not load current roster data"],
|
||||
suggestions=[]
|
||||
)
|
||||
|
||||
# Calculate roster changes from moves
|
||||
ml_changes = 0
|
||||
mil_changes = 0
|
||||
errors = []
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
for move in self.moves:
|
||||
# Calculate roster changes based on from/to locations
|
||||
if move.from_roster == RosterType.MAJOR_LEAGUE:
|
||||
ml_changes -= 1
|
||||
elif move.from_roster == RosterType.MINOR_LEAGUE:
|
||||
mil_changes -= 1
|
||||
# Note: INJURED_LIST and FREE_AGENCY don't count toward ML roster limit
|
||||
|
||||
if move.to_roster == RosterType.MAJOR_LEAGUE:
|
||||
ml_changes += 1
|
||||
elif move.to_roster == RosterType.MINOR_LEAGUE:
|
||||
mil_changes += 1
|
||||
# Note: INJURED_LIST and FREE_AGENCY don't count toward ML roster limit
|
||||
|
||||
# Calculate projected roster sizes
|
||||
# Only Major League players count toward ML roster limit (IL and MiL are separate)
|
||||
current_ml_size = len(self._current_roster.active_players)
|
||||
current_mil_size = len(self._current_roster.minor_league_players)
|
||||
|
||||
projected_ml_size = current_ml_size + ml_changes
|
||||
projected_mil_size = current_mil_size + mil_changes
|
||||
|
||||
# Get current week to determine roster limits
|
||||
try:
|
||||
current_state = await league_service.get_current_state()
|
||||
current_week = current_state.week if current_state else 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get current week, using default limits: {e}")
|
||||
current_week = 1
|
||||
|
||||
# Determine roster limits based on week
|
||||
# Major league: <=26 if week<=14, <=25 if week>14
|
||||
# Minor league: <=6 if week<=14, <=14 if week>14
|
||||
if current_week <= 14:
|
||||
ml_limit = 26
|
||||
mil_limit = 6
|
||||
else:
|
||||
ml_limit = 25
|
||||
mil_limit = 14
|
||||
|
||||
# Validate roster limits
|
||||
is_legal = True
|
||||
if projected_ml_size > ml_limit:
|
||||
is_legal = False
|
||||
errors.append(f"Major League roster would have {projected_ml_size} players (limit: {ml_limit})")
|
||||
suggestions.append(f"Drop {projected_ml_size - ml_limit} ML player(s) to make roster legal")
|
||||
elif projected_ml_size < 0:
|
||||
is_legal = False
|
||||
errors.append("Cannot have negative players on Major League roster")
|
||||
|
||||
if projected_mil_size > mil_limit:
|
||||
is_legal = False
|
||||
errors.append(f"Minor League roster would have {projected_mil_size} players (limit: {mil_limit})")
|
||||
suggestions.append(f"Drop {projected_mil_size - mil_limit} MiL player(s) to make roster legal")
|
||||
elif projected_mil_size < 0:
|
||||
is_legal = False
|
||||
errors.append("Cannot have negative players on Minor League roster")
|
||||
|
||||
# Add suggestions for empty transaction
|
||||
if not self.moves:
|
||||
suggestions.append("Add player moves to build your transaction")
|
||||
|
||||
return RosterValidationResult(
|
||||
is_legal=is_legal,
|
||||
major_league_count=projected_ml_size,
|
||||
minor_league_count=projected_mil_size,
|
||||
warnings=warnings,
|
||||
errors=errors,
|
||||
suggestions=suggestions,
|
||||
major_league_limit=ml_limit,
|
||||
minor_league_limit=mil_limit
|
||||
)
|
||||
|
||||
async def submit_transaction(self, week: int) -> List[Transaction]:
|
||||
"""
|
||||
Submit the transaction by creating individual Transaction models.
|
||||
|
||||
Args:
|
||||
week: Week the transaction is effective for
|
||||
|
||||
Returns:
|
||||
List of created Transaction objects
|
||||
"""
|
||||
if not self.moves:
|
||||
raise ValueError("Cannot submit empty transaction")
|
||||
|
||||
validation = await self.validate_transaction()
|
||||
if not validation.is_legal:
|
||||
raise ValueError(f"Cannot submit illegal transaction: {', '.join(validation.errors)}")
|
||||
|
||||
transactions = []
|
||||
move_id = f"Season-{self.season:03d}-Week-{week:02d}-{int(self.created_at.timestamp())}"
|
||||
|
||||
# Create FA team for drops
|
||||
fa_team = Team(
|
||||
id=503, # Standard FA team ID
|
||||
abbrev="FA",
|
||||
sname="Free Agents",
|
||||
lname="Free Agency",
|
||||
season=self.season
|
||||
)
|
||||
|
||||
for move in self.moves:
|
||||
# Determine old and new teams based on roster locations
|
||||
if move.from_roster == RosterType.FREE_AGENCY:
|
||||
old_team = fa_team
|
||||
else:
|
||||
old_team = move.from_team or self.team
|
||||
|
||||
if move.to_roster == RosterType.FREE_AGENCY:
|
||||
new_team = fa_team
|
||||
else:
|
||||
new_team = move.to_team or self.team
|
||||
|
||||
# For cases where we don't have specific teams, fall back to defaults
|
||||
if not old_team:
|
||||
continue
|
||||
|
||||
# Create transaction
|
||||
transaction = Transaction(
|
||||
id=0, # Will be set by API
|
||||
week=week,
|
||||
season=self.season,
|
||||
moveid=move_id,
|
||||
player=move.player,
|
||||
oldteam=old_team,
|
||||
newteam=new_team,
|
||||
cancelled=False,
|
||||
frozen=False
|
||||
)
|
||||
|
||||
transactions.append(transaction)
|
||||
|
||||
logger.info(f"Created {len(transactions)} transactions for submission with move_id {move_id}")
|
||||
return transactions
|
||||
|
||||
def clear_moves(self) -> None:
|
||||
"""Clear all moves from the transaction builder."""
|
||||
self.moves.clear()
|
||||
logger.info("Cleared all moves from transaction builder")
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Check if transaction builder has no moves."""
|
||||
return len(self.moves) == 0
|
||||
|
||||
@property
|
||||
def move_count(self) -> int:
|
||||
"""Get total number of moves in transaction."""
|
||||
return len(self.moves)
|
||||
|
||||
|
||||
# Global cache for active transaction builders
|
||||
_active_builders: Dict[int, TransactionBuilder] = {}
|
||||
|
||||
|
||||
def get_transaction_builder(user_id: int, team: Team) -> TransactionBuilder:
|
||||
"""
|
||||
Get or create a transaction builder for a user.
|
||||
|
||||
Args:
|
||||
user_id: Discord user ID
|
||||
team: Team object
|
||||
|
||||
Returns:
|
||||
TransactionBuilder instance
|
||||
"""
|
||||
if user_id not in _active_builders:
|
||||
_active_builders[user_id] = TransactionBuilder(team, user_id)
|
||||
|
||||
return _active_builders[user_id]
|
||||
|
||||
|
||||
def clear_transaction_builder(user_id: int) -> None:
|
||||
"""Clear transaction builder for a user."""
|
||||
if user_id in _active_builders:
|
||||
del _active_builders[user_id]
|
||||
logger.info(f"Cleared transaction builder for user {user_id}")
|
||||
268
services/transaction_service.py
Normal file
268
services/transaction_service.py
Normal file
@ -0,0 +1,268 @@
|
||||
"""
|
||||
Transaction service for Discord Bot v2.0
|
||||
|
||||
Handles transaction CRUD operations and business logic.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, List, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
from services.base_service import BaseService
|
||||
from models.transaction import Transaction, RosterValidation
|
||||
from models.roster import TeamRoster
|
||||
from exceptions import APIException
|
||||
|
||||
logger = logging.getLogger(f'{__name__}.TransactionService')
|
||||
|
||||
|
||||
class TransactionService(BaseService[Transaction]):
|
||||
"""Service for transaction operations."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize transaction service."""
|
||||
super().__init__(
|
||||
model_class=Transaction,
|
||||
endpoint='transactions'
|
||||
)
|
||||
logger.debug("TransactionService initialized")
|
||||
|
||||
async def get_team_transactions(
|
||||
self,
|
||||
team_abbrev: str,
|
||||
season: int,
|
||||
cancelled: Optional[bool] = None,
|
||||
frozen: Optional[bool] = None,
|
||||
week_start: Optional[int] = None,
|
||||
week_end: Optional[int] = None
|
||||
) -> List[Transaction]:
|
||||
"""
|
||||
Get transactions for a specific team.
|
||||
|
||||
Args:
|
||||
team_abbrev: Team abbreviation
|
||||
season: Season number
|
||||
cancelled: Filter by cancelled status
|
||||
frozen: Filter by frozen status
|
||||
week_start: Start week for filtering
|
||||
week_end: End week for filtering
|
||||
|
||||
Returns:
|
||||
List of matching transactions
|
||||
"""
|
||||
try:
|
||||
params = [
|
||||
('season', str(season)),
|
||||
('team_abbrev', team_abbrev)
|
||||
]
|
||||
|
||||
if cancelled is not None:
|
||||
params.append(('cancelled', str(cancelled).lower()))
|
||||
if frozen is not None:
|
||||
params.append(('frozen', str(frozen).lower()))
|
||||
if week_start is not None:
|
||||
params.append(('week_start', str(week_start)))
|
||||
if week_end is not None:
|
||||
params.append(('week_end', str(week_end)))
|
||||
|
||||
transactions = await self.get_all_items(params=params)
|
||||
|
||||
# Sort by week, then by moveid
|
||||
transactions.sort(key=lambda t: (t.week, t.moveid))
|
||||
|
||||
logger.debug(f"Retrieved {len(transactions)} transactions for {team_abbrev}")
|
||||
return transactions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting transactions for team {team_abbrev}: {e}")
|
||||
raise APIException(f"Failed to retrieve transactions: {e}")
|
||||
|
||||
async def get_pending_transactions(self, team_abbrev: str, season: int) -> List[Transaction]:
|
||||
"""Get pending transactions for a team."""
|
||||
return await self.get_team_transactions(
|
||||
team_abbrev,
|
||||
season,
|
||||
cancelled=False,
|
||||
frozen=False
|
||||
)
|
||||
|
||||
async def get_frozen_transactions(self, team_abbrev: str, season: int) -> List[Transaction]:
|
||||
"""Get frozen (scheduled for processing) transactions for a team."""
|
||||
return await self.get_team_transactions(
|
||||
team_abbrev,
|
||||
season,
|
||||
frozen=True
|
||||
)
|
||||
|
||||
async def get_processed_transactions(
|
||||
self,
|
||||
team_abbrev: str,
|
||||
season: int,
|
||||
recent_weeks: int = 4
|
||||
) -> List[Transaction]:
|
||||
"""Get recently processed transactions for a team."""
|
||||
# Get current week to limit search
|
||||
try:
|
||||
current_data = await self.get_client()
|
||||
current_response = await current_data.get('current')
|
||||
current_week = current_response.get('week', 0) if current_response else 0
|
||||
|
||||
week_start = max(1, current_week - recent_weeks)
|
||||
|
||||
# For processed transactions, we need to filter by completed/processed status
|
||||
# Since the API structure doesn't have a processed status, we'll get all non-pending/non-frozen
|
||||
all_transactions = await self.get_team_transactions(
|
||||
team_abbrev,
|
||||
season,
|
||||
week_start=week_start
|
||||
)
|
||||
# Filter for transactions that are neither pending nor frozen (i.e., processed)
|
||||
processed = [t for t in all_transactions if not t.is_pending and not t.is_frozen and not t.cancelled]
|
||||
return processed
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get current week, using basic query: {e}")
|
||||
all_transactions = await self.get_team_transactions(
|
||||
team_abbrev,
|
||||
season
|
||||
)
|
||||
# Filter for processed transactions
|
||||
processed = [t for t in all_transactions if not t.is_pending and not t.is_frozen and not t.cancelled]
|
||||
return processed
|
||||
|
||||
async def validate_transaction(self, transaction: Transaction) -> RosterValidation:
|
||||
"""
|
||||
Validate a transaction for legality.
|
||||
|
||||
Args:
|
||||
transaction: Transaction to validate
|
||||
|
||||
Returns:
|
||||
Validation results with any errors or warnings
|
||||
"""
|
||||
try:
|
||||
validation = RosterValidation(is_legal=True)
|
||||
|
||||
# Basic validation rules for single-move transactions
|
||||
if not transaction.player:
|
||||
validation.is_legal = False
|
||||
validation.errors.append("Transaction has no player")
|
||||
|
||||
if not transaction.oldteam or not transaction.newteam:
|
||||
validation.is_legal = False
|
||||
validation.errors.append("Transaction missing team information")
|
||||
|
||||
# Validate player eligibility (basic checks)
|
||||
if transaction.player and transaction.player.wara < 0:
|
||||
validation.warnings.append("Player has negative WARA")
|
||||
|
||||
# Add more validation logic as needed
|
||||
# - Roster size limits
|
||||
# - Position requirements
|
||||
# - Contract constraints
|
||||
# - etc.
|
||||
|
||||
logger.debug(f"Validated transaction {transaction.id}: legal={validation.is_legal}")
|
||||
return validation
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating transaction {transaction.id}: {e}")
|
||||
# Return failed validation on error
|
||||
return RosterValidation(
|
||||
is_legal=False,
|
||||
errors=[f"Validation error: {str(e)}"]
|
||||
)
|
||||
|
||||
async def cancel_transaction(self, transaction_id: str) -> bool:
|
||||
"""
|
||||
Cancel a pending transaction.
|
||||
|
||||
Args:
|
||||
transaction_id: ID of transaction to cancel
|
||||
|
||||
Returns:
|
||||
True if cancelled successfully
|
||||
"""
|
||||
try:
|
||||
transaction = await self.get_by_id(transaction_id)
|
||||
if not transaction:
|
||||
return False
|
||||
|
||||
if not transaction.is_pending:
|
||||
logger.warning(f"Cannot cancel transaction {transaction_id}: not pending (cancelled={transaction.cancelled}, frozen={transaction.frozen})")
|
||||
return False
|
||||
|
||||
# Update transaction status
|
||||
update_data = {
|
||||
'cancelled': True,
|
||||
'cancelled_at': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
updated_transaction = await self.update(transaction_id, update_data)
|
||||
|
||||
if updated_transaction:
|
||||
logger.info(f"Cancelled transaction {transaction_id}")
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling transaction {transaction_id}: {e}")
|
||||
return False
|
||||
|
||||
async def get_contested_transactions(self, season: int, week: int) -> List[Transaction]:
|
||||
"""
|
||||
Get transactions that may be contested (multiple teams want same player).
|
||||
|
||||
Args:
|
||||
season: Season number
|
||||
week: Week number
|
||||
|
||||
Returns:
|
||||
List of potentially contested transactions
|
||||
"""
|
||||
try:
|
||||
# Get all pending transactions for the week
|
||||
params = [
|
||||
('season', str(season)),
|
||||
('week', str(week)),
|
||||
('cancelled', 'false'),
|
||||
('frozen', 'false')
|
||||
]
|
||||
|
||||
transactions = await self.get_all_items(params=params)
|
||||
|
||||
# Group by players being targeted (simplified contest detection)
|
||||
player_target_map = {}
|
||||
contested = []
|
||||
|
||||
for transaction in transactions:
|
||||
# In the new model, each transaction is a single player move
|
||||
# Contest occurs when multiple teams try to acquire the same player
|
||||
if transaction.newteam.abbrev != 'FA': # Not dropping to free agency
|
||||
player_name = transaction.player.name.lower()
|
||||
if player_name not in player_target_map:
|
||||
player_target_map[player_name] = []
|
||||
player_target_map[player_name].append(transaction)
|
||||
|
||||
# Find contested players (wanted by multiple teams)
|
||||
for player_name, player_transactions in player_target_map.items():
|
||||
if len(player_transactions) > 1:
|
||||
contested.extend(player_transactions)
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
result = []
|
||||
for transaction in contested:
|
||||
if transaction.id not in seen:
|
||||
seen.add(transaction.id)
|
||||
result.append(transaction)
|
||||
|
||||
logger.debug(f"Found {len(result)} potentially contested transactions for week {week}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting contested transactions: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Global service instance
|
||||
transaction_service = TransactionService()
|
||||
549
views/transaction_embed.py
Normal file
549
views/transaction_embed.py
Normal file
@ -0,0 +1,549 @@
|
||||
"""
|
||||
Interactive Transaction Embed Views
|
||||
|
||||
Handles the Discord embed and button interfaces for the transaction builder.
|
||||
"""
|
||||
import discord
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from services.transaction_builder import TransactionBuilder, RosterValidationResult
|
||||
from views.embeds import EmbedColors, EmbedTemplate
|
||||
|
||||
|
||||
class TransactionEmbedView(discord.ui.View):
|
||||
"""Interactive view for the transaction builder embed."""
|
||||
|
||||
def __init__(self, builder: TransactionBuilder, user_id: int):
|
||||
"""
|
||||
Initialize the transaction embed view.
|
||||
|
||||
Args:
|
||||
builder: TransactionBuilder instance
|
||||
user_id: Discord user ID (for permission checking)
|
||||
"""
|
||||
super().__init__(timeout=900.0) # 15 minute timeout
|
||||
self.builder = builder
|
||||
self.user_id = user_id
|
||||
|
||||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||||
"""Check if user has permission to interact with this view."""
|
||||
if interaction.user.id != self.user_id:
|
||||
await interaction.response.send_message(
|
||||
"❌ You don't have permission to use this transaction builder.",
|
||||
ephemeral=True
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def on_timeout(self) -> None:
|
||||
"""Handle view timeout."""
|
||||
# Disable all buttons when timeout occurs
|
||||
for item in self.children:
|
||||
if isinstance(item, discord.ui.Button):
|
||||
item.disabled = True
|
||||
|
||||
@discord.ui.button(label="Add Move", style=discord.ButtonStyle.green, emoji="➕")
|
||||
async def add_move_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Handle add move button click."""
|
||||
# Create modal for player selection
|
||||
modal = PlayerSelectionModal(self.builder)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
@discord.ui.button(label="Remove Move", style=discord.ButtonStyle.red, emoji="➖")
|
||||
async def remove_move_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Handle remove move button click."""
|
||||
if self.builder.is_empty:
|
||||
await interaction.response.send_message(
|
||||
"❌ No moves to remove. Add some moves first!",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Create select menu for move removal
|
||||
select_view = RemoveMoveView(self.builder, self.user_id)
|
||||
embed = await create_transaction_embed(self.builder)
|
||||
|
||||
await interaction.response.edit_message(embed=embed, view=select_view)
|
||||
|
||||
@discord.ui.button(label="Preview", style=discord.ButtonStyle.blurple, emoji="👁️")
|
||||
async def preview_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Handle preview button click."""
|
||||
if self.builder.is_empty:
|
||||
await interaction.response.send_message(
|
||||
"❌ No moves to preview. Add some moves first!",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Show detailed preview
|
||||
embed = await create_preview_embed(self.builder)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@discord.ui.button(label="Submit Transaction", style=discord.ButtonStyle.primary, emoji="📤")
|
||||
async def submit_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Handle submit transaction button click."""
|
||||
if self.builder.is_empty:
|
||||
await interaction.response.send_message(
|
||||
"❌ Cannot submit empty transaction. Add some moves first!",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Validate before submission
|
||||
validation = await self.builder.validate_transaction()
|
||||
if not validation.is_legal:
|
||||
error_msg = "❌ **Cannot submit illegal transaction:**\n"
|
||||
error_msg += "\n".join([f"• {error}" for error in validation.errors])
|
||||
|
||||
if validation.suggestions:
|
||||
error_msg += "\n\n**Suggestions:**\n"
|
||||
error_msg += "\n".join([f"💡 {suggestion}" for suggestion in validation.suggestions])
|
||||
|
||||
await interaction.response.send_message(error_msg, ephemeral=True)
|
||||
return
|
||||
|
||||
# Show confirmation modal
|
||||
modal = SubmitConfirmationModal(self.builder)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary, emoji="❌")
|
||||
async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Handle cancel button click."""
|
||||
self.builder.clear_moves()
|
||||
embed = await create_transaction_embed(self.builder)
|
||||
|
||||
# Disable all buttons after cancellation
|
||||
for item in self.children:
|
||||
if isinstance(item, discord.ui.Button):
|
||||
item.disabled = True
|
||||
|
||||
await interaction.response.edit_message(
|
||||
content="❌ **Transaction cancelled and cleared.**",
|
||||
embed=embed,
|
||||
view=self
|
||||
)
|
||||
self.stop()
|
||||
|
||||
|
||||
class RemoveMoveView(discord.ui.View):
|
||||
"""View for selecting which move to remove."""
|
||||
|
||||
def __init__(self, builder: TransactionBuilder, user_id: int):
|
||||
super().__init__(timeout=300.0) # 5 minute timeout
|
||||
self.builder = builder
|
||||
self.user_id = user_id
|
||||
|
||||
# Create select menu with current moves
|
||||
if not builder.is_empty:
|
||||
self.add_item(RemoveMoveSelect(builder))
|
||||
|
||||
# Add back button
|
||||
back_button = discord.ui.Button(label="Back", style=discord.ButtonStyle.secondary, emoji="⬅️")
|
||||
back_button.callback = self.back_callback
|
||||
self.add_item(back_button)
|
||||
|
||||
async def back_callback(self, interaction: discord.Interaction):
|
||||
"""Handle back button to return to main view."""
|
||||
main_view = TransactionEmbedView(self.builder, self.user_id)
|
||||
embed = await create_transaction_embed(self.builder)
|
||||
await interaction.response.edit_message(embed=embed, view=main_view)
|
||||
|
||||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||||
"""Check if user has permission to interact with this view."""
|
||||
return interaction.user.id == self.user_id
|
||||
|
||||
|
||||
class RemoveMoveSelect(discord.ui.Select):
|
||||
"""Select menu for choosing which move to remove."""
|
||||
|
||||
def __init__(self, builder: TransactionBuilder):
|
||||
self.builder = builder
|
||||
|
||||
# Create options from current moves
|
||||
options = []
|
||||
for i, move in enumerate(builder.moves[:25]): # Discord limit of 25 options
|
||||
options.append(discord.SelectOption(
|
||||
label=f"{move.player.name}",
|
||||
description=move.description[:100], # Discord description limit
|
||||
value=str(move.player.id)
|
||||
))
|
||||
|
||||
super().__init__(
|
||||
placeholder="Select a move to remove...",
|
||||
min_values=1,
|
||||
max_values=1,
|
||||
options=options
|
||||
)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
"""Handle move removal selection."""
|
||||
player_id = int(self.values[0])
|
||||
move = self.builder.get_move_for_player(player_id)
|
||||
|
||||
if move:
|
||||
self.builder.remove_move(player_id)
|
||||
await interaction.response.send_message(
|
||||
f"✅ Removed: {move.description}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
# Update the embed
|
||||
main_view = TransactionEmbedView(self.builder, interaction.user.id)
|
||||
embed = await create_transaction_embed(self.builder)
|
||||
|
||||
# Edit the original message
|
||||
await interaction.edit_original_response(embed=embed, view=main_view)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
"❌ Could not find that move to remove.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
class PlayerSelectionModal(discord.ui.Modal):
|
||||
"""Modal for selecting player and destination."""
|
||||
|
||||
def __init__(self, builder: TransactionBuilder):
|
||||
super().__init__(title=f"Add Move - {builder.team.abbrev}")
|
||||
self.builder = builder
|
||||
|
||||
# Player name input
|
||||
self.player_name = discord.ui.TextInput(
|
||||
label="Player Name",
|
||||
placeholder="Enter player name (e.g., 'Mike Trout')",
|
||||
required=True,
|
||||
max_length=100
|
||||
)
|
||||
|
||||
# Destination input (required)
|
||||
self.destination = discord.ui.TextInput(
|
||||
label="Destination",
|
||||
placeholder="ml (Major League), mil (Minor League), or fa (Free Agency)",
|
||||
required=True,
|
||||
max_length=3
|
||||
)
|
||||
|
||||
self.add_item(self.player_name)
|
||||
self.add_item(self.destination)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
"""Handle modal submission."""
|
||||
await interaction.response.defer()
|
||||
|
||||
try:
|
||||
from services.player_service import player_service
|
||||
from models.team import RosterType
|
||||
from services.transaction_builder import TransactionMove
|
||||
|
||||
# Find player
|
||||
players = await player_service.get_players_by_name(self.player_name.value, self.builder.season)
|
||||
if not players:
|
||||
await interaction.followup.send(
|
||||
f"❌ No players found matching '{self.player_name.value}'",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Use exact match if available, otherwise first result
|
||||
player = None
|
||||
for p in players:
|
||||
if p.name.lower() == self.player_name.value.lower():
|
||||
player = p
|
||||
break
|
||||
|
||||
if not player:
|
||||
player = players[0] # Use first match
|
||||
|
||||
# Parse destination
|
||||
destination_map = {
|
||||
"ml": RosterType.MAJOR_LEAGUE,
|
||||
"mil": RosterType.MINOR_LEAGUE,
|
||||
"il": RosterType.INJURED_LIST,
|
||||
"fa": RosterType.FREE_AGENCY
|
||||
}
|
||||
|
||||
to_roster = destination_map.get(self.destination.value.lower())
|
||||
if not to_roster:
|
||||
await interaction.followup.send(
|
||||
f"❌ Invalid destination '{self.destination.value}'. Use: ml, mil, il, or fa",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Determine player's current roster status based on their team
|
||||
if player.team_id == self.builder.team.id:
|
||||
# Player is on the user's team - need to determine which roster
|
||||
# This would need to be enhanced to check actual roster data
|
||||
# For now, we'll assume they're coming from Major League
|
||||
from_roster = RosterType.MAJOR_LEAGUE
|
||||
else:
|
||||
# Player is on another team or free agency
|
||||
from_roster = RosterType.FREE_AGENCY
|
||||
|
||||
# Create move
|
||||
move = TransactionMove(
|
||||
player=player,
|
||||
from_roster=from_roster,
|
||||
to_roster=to_roster,
|
||||
from_team=None if from_roster == RosterType.FREE_AGENCY else self.builder.team,
|
||||
to_team=None if to_roster == RosterType.FREE_AGENCY else self.builder.team
|
||||
)
|
||||
|
||||
# Add move to builder
|
||||
success, error_message = self.builder.add_move(move)
|
||||
if success:
|
||||
await interaction.followup.send(
|
||||
f"✅ Added: {move.description}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
# Update the main embed
|
||||
from views.transaction_embed import TransactionEmbedView
|
||||
embed = await create_transaction_embed(self.builder)
|
||||
view = TransactionEmbedView(self.builder, interaction.user.id)
|
||||
|
||||
# Find and update the original message
|
||||
try:
|
||||
# Get the original interaction from the button press
|
||||
original_message = None
|
||||
async for message in interaction.channel.history(limit=50):
|
||||
if message.author == interaction.client.user and message.embeds:
|
||||
if "Transaction Builder" in message.embeds[0].title:
|
||||
original_message = message
|
||||
break
|
||||
|
||||
if original_message:
|
||||
await original_message.edit(embed=embed, view=view)
|
||||
except Exception as e:
|
||||
# If we can't update the original message, that's okay
|
||||
pass
|
||||
else:
|
||||
await interaction.followup.send(
|
||||
f"❌ {error_message}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await interaction.followup.send(
|
||||
f"❌ Error processing move: {str(e)}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
class SubmitConfirmationModal(discord.ui.Modal):
|
||||
"""Modal for confirming transaction submission."""
|
||||
|
||||
def __init__(self, builder: TransactionBuilder):
|
||||
super().__init__(title="Confirm Transaction Submission")
|
||||
self.builder = builder
|
||||
|
||||
self.confirmation = discord.ui.TextInput(
|
||||
label="Type 'CONFIRM' to submit",
|
||||
placeholder="CONFIRM",
|
||||
required=True,
|
||||
max_length=7
|
||||
)
|
||||
|
||||
self.add_item(self.confirmation)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
"""Handle confirmation submission."""
|
||||
if self.confirmation.value.upper() != "CONFIRM":
|
||||
await interaction.response.send_message(
|
||||
"❌ Transaction not submitted. You must type 'CONFIRM' exactly.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
try:
|
||||
from services.league_service import LeagueService
|
||||
|
||||
# Get current league state
|
||||
league_service = LeagueService()
|
||||
current_state = await league_service.get_current_state()
|
||||
|
||||
if not current_state:
|
||||
await interaction.followup.send(
|
||||
"❌ Could not get current league state. Please try again later.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Submit the transaction (for next week)
|
||||
transactions = await self.builder.submit_transaction(week=current_state.week + 1)
|
||||
|
||||
# Create success message
|
||||
success_msg = f"✅ **Transaction Submitted Successfully!**\n\n"
|
||||
success_msg += f"**Move ID:** `{transactions[0].moveid}`\n"
|
||||
success_msg += f"**Moves:** {len(transactions)}\n"
|
||||
success_msg += f"**Effective Week:** {transactions[0].week}\n\n"
|
||||
|
||||
success_msg += "**Transaction Details:**\n"
|
||||
for move in self.builder.moves:
|
||||
success_msg += f"• {move.description}\n"
|
||||
|
||||
success_msg += f"\n💡 Use `/mymoves` to check transaction status"
|
||||
|
||||
await interaction.followup.send(success_msg, ephemeral=True)
|
||||
|
||||
# Clear the builder after successful submission
|
||||
from services.transaction_builder import clear_transaction_builder
|
||||
clear_transaction_builder(interaction.user.id)
|
||||
|
||||
# Update the original embed to show completion
|
||||
completion_embed = discord.Embed(
|
||||
title="✅ Transaction Submitted",
|
||||
description=f"Your transaction has been submitted successfully!\n\nMove ID: `{transactions[0].moveid}`",
|
||||
color=0x00ff00
|
||||
)
|
||||
|
||||
# Disable all buttons
|
||||
view = discord.ui.View()
|
||||
|
||||
try:
|
||||
# Find and update the original message
|
||||
async for message in interaction.channel.history(limit=50):
|
||||
if message.author == interaction.client.user and message.embeds:
|
||||
if "Transaction Builder" in message.embeds[0].title:
|
||||
await message.edit(embed=completion_embed, view=view)
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
await interaction.followup.send(
|
||||
f"❌ Error submitting transaction: {str(e)}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
async def create_transaction_embed(builder: TransactionBuilder) -> discord.Embed:
|
||||
"""
|
||||
Create the main transaction builder embed.
|
||||
|
||||
Args:
|
||||
builder: TransactionBuilder instance
|
||||
|
||||
Returns:
|
||||
Discord embed with current transaction state
|
||||
"""
|
||||
embed = EmbedTemplate.create_base_embed(
|
||||
title=f"📋 Transaction Builder - {builder.team.abbrev}",
|
||||
description=f"Build your transaction for next week",
|
||||
color=EmbedColors.PRIMARY
|
||||
)
|
||||
|
||||
# Add current moves section
|
||||
if builder.is_empty:
|
||||
embed.add_field(
|
||||
name="Current Moves",
|
||||
value="*No moves yet. Use the buttons below to build your transaction.*",
|
||||
inline=False
|
||||
)
|
||||
else:
|
||||
moves_text = ""
|
||||
for i, move in enumerate(builder.moves[:10], 1): # Limit display
|
||||
moves_text += f"{i}. {move.description}\n"
|
||||
|
||||
if len(builder.moves) > 10:
|
||||
moves_text += f"... and {len(builder.moves) - 10} more moves"
|
||||
|
||||
embed.add_field(
|
||||
name=f"Current Moves ({builder.move_count})",
|
||||
value=moves_text,
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add roster validation
|
||||
validation = await builder.validate_transaction()
|
||||
|
||||
roster_status = f"{validation.major_league_status}\n{validation.minor_league_status}"
|
||||
if not validation.is_legal:
|
||||
roster_status += f"\n✅ Free Agency: Available"
|
||||
else:
|
||||
roster_status += f"\n✅ Free Agency: Available"
|
||||
|
||||
embed.add_field(
|
||||
name="Roster Status",
|
||||
value=roster_status,
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add suggestions/errors
|
||||
if validation.errors:
|
||||
error_text = "\n".join([f"• {error}" for error in validation.errors])
|
||||
embed.add_field(
|
||||
name="❌ Errors",
|
||||
value=error_text,
|
||||
inline=False
|
||||
)
|
||||
|
||||
if validation.suggestions:
|
||||
suggestion_text = "\n".join([f"💡 {suggestion}" for suggestion in validation.suggestions])
|
||||
embed.add_field(
|
||||
name="Suggestions",
|
||||
value=suggestion_text,
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add footer with timestamp
|
||||
embed.set_footer(text=f"Created at {builder.created_at.strftime('%H:%M:%S')}")
|
||||
|
||||
return embed
|
||||
|
||||
|
||||
async def create_preview_embed(builder: TransactionBuilder) -> discord.Embed:
|
||||
"""
|
||||
Create a detailed preview embed for the transaction.
|
||||
|
||||
Args:
|
||||
builder: TransactionBuilder instance
|
||||
|
||||
Returns:
|
||||
Discord embed with transaction preview
|
||||
"""
|
||||
embed = EmbedTemplate.create_base_embed(
|
||||
title=f"📋 Transaction Preview - {builder.team.abbrev}",
|
||||
description="Complete transaction details before submission",
|
||||
color=EmbedColors.WARNING
|
||||
)
|
||||
|
||||
# Add all moves
|
||||
if builder.moves:
|
||||
moves_text = ""
|
||||
for i, move in enumerate(builder.moves, 1):
|
||||
moves_text += f"{i}. {move.description}\n"
|
||||
|
||||
embed.add_field(
|
||||
name=f"All Moves ({len(builder.moves)})",
|
||||
value=moves_text,
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add validation results
|
||||
validation = await builder.validate_transaction()
|
||||
|
||||
status_text = f"{validation.major_league_status}\n{validation.minor_league_status}"
|
||||
embed.add_field(
|
||||
name="Final Roster Status",
|
||||
value=status_text,
|
||||
inline=False
|
||||
)
|
||||
|
||||
if validation.is_legal:
|
||||
embed.add_field(
|
||||
name="✅ Validation",
|
||||
value="Transaction is legal and ready for submission!",
|
||||
inline=False
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="❌ Validation Issues",
|
||||
value="\n".join([f"• {error}" for error in validation.errors]),
|
||||
inline=False
|
||||
)
|
||||
|
||||
return embed
|
||||
Loading…
Reference in New Issue
Block a user