From 13c61fd8ae4d5e2337909e5f80301530a2a35bbb Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 24 Sep 2025 09:32:04 -0500 Subject: [PATCH] Transactions cog in place --- DECORATOR_MIGRATION_GUIDE.md | 326 ----------------- bot.py | 2 + commands/league/README.md | 84 +++++ commands/league/schedule.py | 103 +++--- commands/players/README.md | 104 ++++++ commands/teams/README.md | 134 +++++++ commands/transactions/DESIGN.md | 243 ++++++++++++ commands/transactions/README.md | 130 +++++++ commands/transactions/__init__.py | 52 +++ commands/transactions/dropadd.py | 275 ++++++++++++++ commands/transactions/management.py | 369 +++++++++++++++++++ models/player.py | 37 +- models/roster.py | 148 ++++++++ models/team.py | 36 ++ models/transaction.py | 127 +++++++ services/player_service.py | 51 ++- services/roster_service.py | 191 ++++++++++ services/team_service.py | 48 ++- services/transaction_builder.py | 406 ++++++++++++++++++++ services/transaction_service.py | 268 ++++++++++++++ views/transaction_embed.py | 549 ++++++++++++++++++++++++++++ 21 files changed, 3274 insertions(+), 409 deletions(-) delete mode 100644 DECORATOR_MIGRATION_GUIDE.md create mode 100644 commands/league/README.md create mode 100644 commands/players/README.md create mode 100644 commands/teams/README.md create mode 100644 commands/transactions/DESIGN.md create mode 100644 commands/transactions/README.md create mode 100644 commands/transactions/__init__.py create mode 100644 commands/transactions/dropadd.py create mode 100644 commands/transactions/management.py create mode 100644 models/roster.py create mode 100644 models/transaction.py create mode 100644 services/roster_service.py create mode 100644 services/transaction_builder.py create mode 100644 services/transaction_service.py create mode 100644 views/transaction_embed.py diff --git a/DECORATOR_MIGRATION_GUIDE.md b/DECORATOR_MIGRATION_GUIDE.md deleted file mode 100644 index 8f10824..0000000 --- a/DECORATOR_MIGRATION_GUIDE.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/bot.py b/bot.py index 6e1ed92..a3e9146 100644 --- a/bot.py +++ b/bot.py @@ -113,6 +113,7 @@ class SBABot(commands.Bot): from commands.league import setup_league from commands.custom_commands import setup_custom_commands from commands.admin import setup_admin + from commands.transactions import setup_transactions # Define command packages to load command_packages = [ @@ -121,6 +122,7 @@ class SBABot(commands.Bot): ("league", setup_league), ("custom_commands", setup_custom_commands), ("admin", setup_admin), + ("transactions", setup_transactions), ] total_successful = 0 diff --git a/commands/league/README.md b/commands/league/README.md new file mode 100644 index 0000000..2a7e677 --- /dev/null +++ b/commands/league/README.md @@ -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` \ No newline at end of file diff --git a/commands/league/schedule.py b/commands/league/schedule.py index 80bd725..eb0fb3c 100644 --- a/commands/league/schedule.py +++ b/commands/league/schedule.py @@ -4,6 +4,7 @@ League Schedule Commands Implements slash commands for displaying game schedules and results. """ from typing import Optional +import asyncio import discord from discord.ext import commands @@ -42,27 +43,17 @@ class ScheduleCommands(commands.Cog): """Display game schedule for a week or team.""" await interaction.response.defer() - try: - search_season = season or SBA_CURRENT_SEASON - - if team: - # Show team schedule - await self._show_team_schedule(interaction, search_season, team, week) - elif week: - # Show specific week schedule - await self._show_week_schedule(interaction, search_season, week) - else: - # Show recent/upcoming games - await self._show_current_schedule(interaction, search_season) - - except Exception as e: - error_msg = f"❌ Error retrieving schedule: {str(e)}" - - if interaction.response.is_done(): - await interaction.followup.send(error_msg, ephemeral=True) - else: - await interaction.response.send_message(error_msg, ephemeral=True) - raise + search_season = season or SBA_CURRENT_SEASON + + if team: + # Show team schedule + await self._show_team_schedule(interaction, search_season, team, week) + elif week: + # Show specific week schedule + await self._show_week_schedule(interaction, search_season, week) + else: + # Show recent/upcoming games + await self._show_current_schedule(interaction, search_season) @discord.app_commands.command( name="results", @@ -82,45 +73,35 @@ class ScheduleCommands(commands.Cog): """Display recent game results.""" await interaction.response.defer() - try: - search_season = season or SBA_CURRENT_SEASON + search_season = season or SBA_CURRENT_SEASON + + if week: + # Show specific week results + games = await schedule_service.get_week_schedule(search_season, week) + completed_games = [game for game in games if game.is_completed] - if week: - # Show specific week results - games = await schedule_service.get_week_schedule(search_season, week) - completed_games = [game for game in games if game.is_completed] - - if not completed_games: - await interaction.followup.send( - f"❌ No completed games found for season {search_season}, week {week}.", - ephemeral=True - ) - return - - embed = await self._create_week_results_embed(completed_games, search_season, week) - await interaction.followup.send(embed=embed) - else: - # Show recent results - recent_games = await schedule_service.get_recent_games(search_season) - - if not recent_games: - await interaction.followup.send( - f"❌ No recent games found for season {search_season}.", - ephemeral=True - ) - return - - embed = await self._create_recent_results_embed(recent_games, search_season) - await interaction.followup.send(embed=embed) - - except Exception as e: - error_msg = f"❌ Error retrieving results: {str(e)}" + if not completed_games: + await interaction.followup.send( + f"❌ No completed games found for season {search_season}, week {week}.", + ephemeral=True + ) + return - if interaction.response.is_done(): - await interaction.followup.send(error_msg, ephemeral=True) - else: - await interaction.response.send_message(error_msg, ephemeral=True) - raise + embed = await self._create_week_results_embed(completed_games, search_season, week) + await interaction.followup.send(embed=embed) + else: + # Show recent results + recent_games = await schedule_service.get_recent_games(search_season) + + if not recent_games: + await interaction.followup.send( + f"❌ No recent games found for season {search_season}.", + ephemeral=True + ) + return + + embed = await self._create_recent_results_embed(recent_games, search_season) + await interaction.followup.send(embed=embed) async def _show_week_schedule(self, interaction: discord.Interaction, season: int, week: int): """Show schedule for a specific week.""" @@ -169,8 +150,10 @@ class ScheduleCommands(commands.Cog): self.logger.debug("Fetching current schedule overview", season=season) # Get both recent and upcoming games - recent_games = await schedule_service.get_recent_games(season, weeks_back=1) - upcoming_games = await schedule_service.get_upcoming_games(season, weeks_ahead=1) + recent_games, upcoming_games = await asyncio.gather( + schedule_service.get_recent_games(season, weeks_back=1), + schedule_service.get_upcoming_games(season, weeks_ahead=1) + ) if not recent_games and not upcoming_games: await interaction.followup.send( diff --git a/commands/players/README.md b/commands/players/README.md new file mode 100644 index 0000000..cf55919 --- /dev/null +++ b/commands/players/README.md @@ -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 \ No newline at end of file diff --git a/commands/teams/README.md b/commands/teams/README.md new file mode 100644 index 0000000..773850c --- /dev/null +++ b/commands/teams/README.md @@ -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 \ No newline at end of file diff --git a/commands/transactions/DESIGN.md b/commands/transactions/DESIGN.md new file mode 100644 index 0000000..87cd5ae --- /dev/null +++ b/commands/transactions/DESIGN.md @@ -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 \ No newline at end of file diff --git a/commands/transactions/README.md b/commands/transactions/README.md new file mode 100644 index 0000000..d403a78 --- /dev/null +++ b/commands/transactions/README.md @@ -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 \ No newline at end of file diff --git a/commands/transactions/__init__.py b/commands/transactions/__init__.py new file mode 100644 index 0000000..0477c8d --- /dev/null +++ b/commands/transactions/__init__.py @@ -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 \ No newline at end of file diff --git a/commands/transactions/dropadd.py b/commands/transactions/dropadd.py new file mode 100644 index 0000000..be467af --- /dev/null +++ b/commands/transactions/dropadd.py @@ -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)) \ No newline at end of file diff --git a/commands/transactions/management.py b/commands/transactions/management.py new file mode 100644 index 0000000..1851baf --- /dev/null +++ b/commands/transactions/management.py @@ -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)) \ No newline at end of file diff --git a/models/player.py b/models/player.py index ec07f80..d467979 100644 --- a/models/player.py +++ b/models/player.py @@ -82,20 +82,29 @@ class Player(SBABaseModel): # Make a copy to avoid modifying original data player_data = data.copy() - # Handle nested team structure - if 'team' in player_data and isinstance(player_data['team'], dict): - team_data = player_data['team'] - # Extract team_id from nested team object - player_data['team_id'] = team_data.get('id') - # Keep team object for optional population - if team_data.get('id'): - from models.team import Team - player_data['team'] = Team.from_api_data(team_data) - - # Handle sbaplayer structure (convert to SBAPlayer model) - if 'sbaplayer' in player_data and isinstance(player_data['sbaplayer'], dict): - sba_data = player_data['sbaplayer'] - player_data['sbaplayer'] = SBAPlayer.from_api_data(sba_data) + # Handle team structure - can be nested object or just ID + if 'team' in player_data: + if isinstance(player_data['team'], dict): + # Nested team object from regular endpoints + team_data = player_data['team'] + player_data['team_id'] = team_data.get('id') + if team_data.get('id'): + from models.team import Team + player_data['team'] = Team.from_api_data(team_data) + elif isinstance(player_data['team'], int): + # Team ID only from search endpoints + player_data['team_id'] = player_data['team'] + player_data['team'] = None # No nested team object available + + # Handle sbaplayer structure - can be nested object or just ID + if 'sbaplayer' in player_data: + if isinstance(player_data['sbaplayer'], dict): + # Nested sbaplayer object + sba_data = player_data['sbaplayer'] + player_data['sbaplayer'] = SBAPlayer.from_api_data(sba_data) + elif isinstance(player_data['sbaplayer'], int): + # SBA player ID only from search endpoints + player_data['sbaplayer'] = None # No nested object available return super().from_api_data(player_data) diff --git a/models/roster.py b/models/roster.py new file mode 100644 index 0000000..c6a1d52 --- /dev/null +++ b/models/roster.py @@ -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) \ No newline at end of file diff --git a/models/team.py b/models/team.py index 66766d4..248a484 100644 --- a/models/team.py +++ b/models/team.py @@ -4,12 +4,21 @@ Team model for SBA teams Represents a team in the league with all associated metadata. """ from typing import Optional +from enum import Enum from pydantic import Field from models.base import SBABaseModel from models.division import Division +class RosterType(Enum): + """Roster designation types.""" + MAJOR_LEAGUE = "ml" + MINOR_LEAGUE = "mil" + INJURED_LIST = "il" + FREE_AGENCY = "fa" + + class Team(SBABaseModel): """Team model representing an SBA team.""" @@ -57,5 +66,32 @@ class Team(SBABaseModel): return super().from_api_data(team_data) + def roster_type(self) -> RosterType: + """Determine the roster type based on team abbreviation.""" + if len(self.abbrev) <= 3: + return RosterType.MAJOR_LEAGUE + + # For teams with extended abbreviations, check suffix patterns + abbrev_lower = self.abbrev.lower() + + # Pattern analysis: + # - Minor League: ends with 'mil' (e.g., NYYMIL, BHMMIL) + # - Injured List: ends with 'il' but not 'mil' (e.g., NYYIL, BOSIL) + # - Edge case: teams whose base abbrev ends in 'M' + 'IL' = 'MIL' + # Only applies if removing 'IL' gives us exactly a 3-char base team + + if abbrev_lower.endswith('mil'): + # Check if this is actually [BaseTeam]IL where BaseTeam ends in 'M' + # E.g., BHMIL = BHM + IL (injured list), not minor league + if len(self.abbrev) == 5: # Exactly 5 chars: 3-char base + IL + potential_base = self.abbrev[:-2] # Remove 'IL' + if len(potential_base) == 3 and potential_base.upper().endswith('M'): + return RosterType.INJURED_LIST + return RosterType.MINOR_LEAGUE + elif abbrev_lower.endswith('il'): + return RosterType.INJURED_LIST + else: + return RosterType.MAJOR_LEAGUE + def __str__(self): return f"{self.abbrev} - {self.lname}" \ No newline at end of file diff --git a/models/transaction.py b/models/transaction.py new file mode 100644 index 0000000..4aa43c8 --- /dev/null +++ b/models/transaction.py @@ -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 "✅" \ No newline at end of file diff --git a/services/player_service.py b/services/player_service.py index 6af82aa..15839eb 100644 --- a/services/player_service.py +++ b/services/player_service.py @@ -133,15 +133,50 @@ class PlayerService(BaseService[Player]): logger.error(f"Error finding exact player match for '{name}': {e}") return None + async def search_players(self, query: str, limit: int = 10, season: Optional[int] = None) -> List[Player]: + """ + Search for players using the dedicated /v3/players/search endpoint. + + Args: + query: Search query for player name + limit: Maximum number of results to return (1-50) + season: Season to search in (defaults to current season) + + Returns: + List of matching players (up to limit) + """ + try: + params = [('q', query), ('limit', str(limit))] + if season is not None: + params.append(('season', str(season))) + + client = await self.get_client() + data = await client.get('players/search', params=params) + + if not data: + logger.debug(f"No players found for search query '{query}'") + return [] + + # Handle API response format: {'count': int, 'players': [...]} + items, count = self._extract_items_and_count_from_response(data) + players = [self.model_class.from_api_data(item) for item in items] + + logger.debug(f"Search '{query}' returned {len(players)} of {count} matches") + return players + + except Exception as e: + logger.error(f"Error in player search for '{query}': {e}") + return [] + async def search_players_fuzzy(self, query: str, limit: int = 10, season: Optional[int] = None) -> List[Player]: """ Fuzzy search for players by name with limit using existing name search functionality. - + Args: query: Search query limit: Maximum results to return season: Season to search in (defaults to current season) - + Returns: List of matching players (up to limit) """ @@ -149,29 +184,29 @@ class PlayerService(BaseService[Player]): if season is None: from constants import SBA_CURRENT_SEASON season = SBA_CURRENT_SEASON - + # Use the existing name-based search that actually works players = await self.get_players_by_name(query, season) - + # Sort by relevance (exact matches first, then partial) query_lower = query.lower() exact_matches = [] partial_matches = [] - + for player in players: name_lower = player.name.lower() if name_lower == query_lower: exact_matches.append(player) elif query_lower in name_lower: partial_matches.append(player) - + # Combine and limit results results = exact_matches + partial_matches limited_results = results[:limit] - + logger.debug(f"Fuzzy search '{query}' returned {len(limited_results)} of {len(results)} matches") return limited_results - + except Exception as e: logger.error(f"Error in fuzzy search for '{query}': {e}") return [] diff --git a/services/roster_service.py b/services/roster_service.py new file mode 100644 index 0000000..d4d02a4 --- /dev/null +++ b/services/roster_service.py @@ -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() \ No newline at end of file diff --git a/services/team_service.py b/services/team_service.py index f8bd20a..91eef5f 100644 --- a/services/team_service.py +++ b/services/team_service.py @@ -7,7 +7,7 @@ import logging from typing import Optional, List, Dict, Any from services.base_service import BaseService -from models.team import Team +from models.team import Team, RosterType from constants import SBA_CURRENT_SEASON from exceptions import APIException @@ -51,6 +51,52 @@ class TeamService(BaseService[Team]): logger.error(f"Unexpected error getting team {team_id}: {e}") return None + async def get_teams_by_owner( + self, + owner_id: int, + season: Optional[int] = None, + roster_type: Optional[str] = None + ) -> List[Team]: + """ + Get teams owned by a specific Discord user. + + Args: + owner_id: Discord user ID + season: Season number (defaults to current season) + roster_type: Filter by roster type ('ml', 'mil', 'il') - optional + + Returns: + List of Team instances owned by the user, optionally filtered by type + """ + try: + season = season or SBA_CURRENT_SEASON + params = [ + ('owner_id', str(owner_id)), + ('season', str(season)) + ] + + teams = await self.get_all_items(params=params) + + # Filter by roster type if specified + if roster_type and teams: + try: + target_type = RosterType(roster_type) + teams = [team for team in teams if team.roster_type() == target_type] + logger.debug(f"Filtered to {len(teams)} {roster_type} teams for owner {owner_id}") + except ValueError: + logger.warning(f"Invalid roster_type '{roster_type}' - returning all teams") + + if teams: + logger.debug(f"Found {len(teams)} teams for owner {owner_id} in season {season}") + return teams + + logger.debug(f"No teams found for owner {owner_id} in season {season}") + return [] + + except Exception as e: + logger.error(f"Error getting teams for owner {owner_id}: {e}") + return [] + async def get_team_by_abbrev(self, abbrev: str, season: Optional[int] = None) -> Optional[Team]: """ Get team by abbreviation for a specific season. diff --git a/services/transaction_builder.py b/services/transaction_builder.py new file mode 100644 index 0000000..18b28ca --- /dev/null +++ b/services/transaction_builder.py @@ -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}") \ No newline at end of file diff --git a/services/transaction_service.py b/services/transaction_service.py new file mode 100644 index 0000000..3224eb8 --- /dev/null +++ b/services/transaction_service.py @@ -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() \ No newline at end of file diff --git a/views/transaction_embed.py b/views/transaction_embed.py new file mode 100644 index 0000000..c151bef --- /dev/null +++ b/views/transaction_embed.py @@ -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 \ No newline at end of file