Transactions cog in place

This commit is contained in:
Cal Corum 2025-09-24 09:32:04 -05:00
parent 7b41520054
commit 13c61fd8ae
21 changed files with 3274 additions and 409 deletions

View File

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

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

View File

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

View 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

View 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

View 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

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

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

View File

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

View File

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

View File

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

View File

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

View 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}")

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