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.league import setup_league
|
||||||
from commands.custom_commands import setup_custom_commands
|
from commands.custom_commands import setup_custom_commands
|
||||||
from commands.admin import setup_admin
|
from commands.admin import setup_admin
|
||||||
|
from commands.transactions import setup_transactions
|
||||||
|
|
||||||
# Define command packages to load
|
# Define command packages to load
|
||||||
command_packages = [
|
command_packages = [
|
||||||
@ -121,6 +122,7 @@ class SBABot(commands.Bot):
|
|||||||
("league", setup_league),
|
("league", setup_league),
|
||||||
("custom_commands", setup_custom_commands),
|
("custom_commands", setup_custom_commands),
|
||||||
("admin", setup_admin),
|
("admin", setup_admin),
|
||||||
|
("transactions", setup_transactions),
|
||||||
]
|
]
|
||||||
|
|
||||||
total_successful = 0
|
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.
|
Implements slash commands for displaying game schedules and results.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
@ -42,27 +43,17 @@ class ScheduleCommands(commands.Cog):
|
|||||||
"""Display game schedule for a week or team."""
|
"""Display game schedule for a week or team."""
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
|
||||||
try:
|
search_season = season or SBA_CURRENT_SEASON
|
||||||
search_season = season or SBA_CURRENT_SEASON
|
|
||||||
|
|
||||||
if team:
|
if team:
|
||||||
# Show team schedule
|
# Show team schedule
|
||||||
await self._show_team_schedule(interaction, search_season, team, week)
|
await self._show_team_schedule(interaction, search_season, team, week)
|
||||||
elif week:
|
elif week:
|
||||||
# Show specific week schedule
|
# Show specific week schedule
|
||||||
await self._show_week_schedule(interaction, search_season, week)
|
await self._show_week_schedule(interaction, search_season, week)
|
||||||
else:
|
else:
|
||||||
# Show recent/upcoming games
|
# Show recent/upcoming games
|
||||||
await self._show_current_schedule(interaction, search_season)
|
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
|
|
||||||
|
|
||||||
@discord.app_commands.command(
|
@discord.app_commands.command(
|
||||||
name="results",
|
name="results",
|
||||||
@ -82,45 +73,35 @@ class ScheduleCommands(commands.Cog):
|
|||||||
"""Display recent game results."""
|
"""Display recent game results."""
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
|
||||||
try:
|
search_season = season or SBA_CURRENT_SEASON
|
||||||
search_season = season or SBA_CURRENT_SEASON
|
|
||||||
|
|
||||||
if week:
|
if week:
|
||||||
# Show specific week results
|
# Show specific week results
|
||||||
games = await schedule_service.get_week_schedule(search_season, week)
|
games = await schedule_service.get_week_schedule(search_season, week)
|
||||||
completed_games = [game for game in games if game.is_completed]
|
completed_games = [game for game in games if game.is_completed]
|
||||||
|
|
||||||
if not completed_games:
|
if not completed_games:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
f"❌ No completed games found for season {search_season}, week {week}.",
|
f"❌ No completed games found for season {search_season}, week {week}.",
|
||||||
ephemeral=True
|
ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
embed = await self._create_week_results_embed(completed_games, search_season, week)
|
embed = await self._create_week_results_embed(completed_games, search_season, week)
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.followup.send(embed=embed)
|
||||||
else:
|
else:
|
||||||
# Show recent results
|
# Show recent results
|
||||||
recent_games = await schedule_service.get_recent_games(search_season)
|
recent_games = await schedule_service.get_recent_games(search_season)
|
||||||
|
|
||||||
if not recent_games:
|
if not recent_games:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
f"❌ No recent games found for season {search_season}.",
|
f"❌ No recent games found for season {search_season}.",
|
||||||
ephemeral=True
|
ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
embed = await self._create_recent_results_embed(recent_games, search_season)
|
embed = await self._create_recent_results_embed(recent_games, search_season)
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.followup.send(embed=embed)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
async def _show_week_schedule(self, interaction: discord.Interaction, season: int, week: int):
|
async def _show_week_schedule(self, interaction: discord.Interaction, season: int, week: int):
|
||||||
"""Show schedule for a specific week."""
|
"""Show schedule for a specific week."""
|
||||||
@ -169,8 +150,10 @@ class ScheduleCommands(commands.Cog):
|
|||||||
self.logger.debug("Fetching current schedule overview", season=season)
|
self.logger.debug("Fetching current schedule overview", season=season)
|
||||||
|
|
||||||
# Get both recent and upcoming games
|
# Get both recent and upcoming games
|
||||||
recent_games = await schedule_service.get_recent_games(season, weeks_back=1)
|
recent_games, upcoming_games = await asyncio.gather(
|
||||||
upcoming_games = await schedule_service.get_upcoming_games(season, weeks_ahead=1)
|
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:
|
if not recent_games and not upcoming_games:
|
||||||
await interaction.followup.send(
|
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
|
# Make a copy to avoid modifying original data
|
||||||
player_data = data.copy()
|
player_data = data.copy()
|
||||||
|
|
||||||
# Handle nested team structure
|
# Handle team structure - can be nested object or just ID
|
||||||
if 'team' in player_data and isinstance(player_data['team'], dict):
|
if 'team' in player_data:
|
||||||
team_data = player_data['team']
|
if isinstance(player_data['team'], dict):
|
||||||
# Extract team_id from nested team object
|
# Nested team object from regular endpoints
|
||||||
player_data['team_id'] = team_data.get('id')
|
team_data = player_data['team']
|
||||||
# Keep team object for optional population
|
player_data['team_id'] = team_data.get('id')
|
||||||
if team_data.get('id'):
|
if team_data.get('id'):
|
||||||
from models.team import Team
|
from models.team import Team
|
||||||
player_data['team'] = Team.from_api_data(team_data)
|
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)
|
# Handle sbaplayer structure - can be nested object or just ID
|
||||||
if 'sbaplayer' in player_data and isinstance(player_data['sbaplayer'], dict):
|
if 'sbaplayer' in player_data:
|
||||||
sba_data = player_data['sbaplayer']
|
if isinstance(player_data['sbaplayer'], dict):
|
||||||
player_data['sbaplayer'] = SBAPlayer.from_api_data(sba_data)
|
# 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)
|
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.
|
Represents a team in the league with all associated metadata.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from models.base import SBABaseModel
|
from models.base import SBABaseModel
|
||||||
from models.division import Division
|
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):
|
class Team(SBABaseModel):
|
||||||
"""Team model representing an SBA team."""
|
"""Team model representing an SBA team."""
|
||||||
|
|
||||||
@ -57,5 +66,32 @@ class Team(SBABaseModel):
|
|||||||
|
|
||||||
return super().from_api_data(team_data)
|
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):
|
def __str__(self):
|
||||||
return f"{self.abbrev} - {self.lname}"
|
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}")
|
logger.error(f"Error finding exact player match for '{name}': {e}")
|
||||||
return None
|
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]:
|
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.
|
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 typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
from services.base_service import BaseService
|
from services.base_service import BaseService
|
||||||
from models.team import Team
|
from models.team import Team, RosterType
|
||||||
from constants import SBA_CURRENT_SEASON
|
from constants import SBA_CURRENT_SEASON
|
||||||
from exceptions import APIException
|
from exceptions import APIException
|
||||||
|
|
||||||
@ -51,6 +51,52 @@ class TeamService(BaseService[Team]):
|
|||||||
logger.error(f"Unexpected error getting team {team_id}: {e}")
|
logger.error(f"Unexpected error getting team {team_id}: {e}")
|
||||||
return None
|
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]:
|
async def get_team_by_abbrev(self, abbrev: str, season: Optional[int] = None) -> Optional[Team]:
|
||||||
"""
|
"""
|
||||||
Get team by abbreviation for a specific season.
|
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