diff --git a/CLAUDE.md b/CLAUDE.md index 4a473c5..000a1a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -513,7 +513,30 @@ jq 'select(.extra.duration_ms > 5000)' logs/discord_bot_v2.json - [ ] Added tests following established patterns - [ ] Verified all tests pass -## šŸ”„ Recent Major Enhancements (January 2025) +## šŸ”„ Recent Major Enhancements + +### Multi-GM Trade Access (December 2025) +**Enables any GM participating in a trade to access and modify the trade builder** + +**Problem Solved**: Previously, only the user who initiated a trade (`/trade initiate`) could add players, view, or modify the trade. Other GMs whose teams were part of the trade had no access. + +**Solution**: Implemented dual-key indexing pattern with a secondary index mapping team IDs to trade keys. + +**Key Changes**: +- **`services/trade_builder.py`**: + - Added `_team_to_trade_key` secondary index + - Added `get_trade_builder_by_team(team_id)` function + - Added `clear_trade_builder_by_team(team_id)` function + - Updated `add_team()`, `remove_team()`, `clear_trade_builder()` to maintain index +- **`commands/transactions/trade.py`**: + - Refactored 5 commands to use team-based lookups: `add-team`, `add-player`, `supplementary`, `view`, `clear` + - Any GM whose team is in the trade can now use these commands + +**Docker Images**: +- `manticorum67/major-domo-discordapp:2.22.0` +- `manticorum67/major-domo-discordapp:dev` + +**Tests**: 9 new tests added to `tests/test_services_trade_builder.py` ### Custom Help Commands System (January 2025) **Comprehensive admin-managed help system for league documentation**: diff --git a/VERSION b/VERSION index db65e21..f48f82f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.21.0 +2.22.0 diff --git a/commands/transactions/trade.py b/commands/transactions/trade.py index b603c0e..52e0faa 100644 --- a/commands/transactions/trade.py +++ b/commands/transactions/trade.py @@ -183,7 +183,7 @@ class TradeCommands(commands.Cog): other_team: str ): """Add a team to an existing trade.""" - await interaction.response.defer(ephemeral=True) + await interaction.response.defer(ephemeral=False) # Get user's team first user_team = await validate_user_has_team(interaction) @@ -266,7 +266,7 @@ class TradeCommands(commands.Cog): destination_team: str ): """Add a player move to the trade.""" - await interaction.response.defer(ephemeral=True) + await interaction.response.defer(ephemeral=False) # Get user's team first user_team = await validate_user_has_team(interaction) @@ -373,7 +373,7 @@ class TradeCommands(commands.Cog): destination: str ): """Add a supplementary (internal organization) move for roster legality.""" - await interaction.response.defer(ephemeral=True) + await interaction.response.defer(ephemeral=False) # Get user's team first user_team = await validate_user_has_team(interaction) @@ -464,7 +464,7 @@ class TradeCommands(commands.Cog): @logged_command("/trade view") async def trade_view(self, interaction: discord.Interaction): """View the current trade.""" - await interaction.response.defer(ephemeral=True) + await interaction.response.defer(ephemeral=False) # Get user's team first user_team = await validate_user_has_team(interaction) @@ -506,7 +506,7 @@ class TradeCommands(commands.Cog): @logged_command("/trade clear") async def trade_clear(self, interaction: discord.Interaction): """Clear the current trade.""" - await interaction.response.defer(ephemeral=True) + await interaction.response.defer(ephemeral=False) # Get user's team first user_team = await validate_user_has_team(interaction) diff --git a/services/trade_builder.py b/services/trade_builder.py index 73d14f7..a129928 100644 --- a/services/trade_builder.py +++ b/services/trade_builder.py @@ -4,7 +4,7 @@ Trade Builder Service Extends the TransactionBuilder to support multi-team trades and player exchanges. """ import logging -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Set, Tuple from datetime import datetime, timezone import uuid @@ -90,6 +90,9 @@ class TradeBuilder: # Cache transaction builders for each participating team self._team_builders: Dict[int, TransactionBuilder] = {} + # Track which teams have accepted the trade (team_id -> True) + self.accepted_teams: Set[int] = set() + logger.info(f"TradeBuilder initialized: {self.trade.trade_id} by user {initiated_by} for {initiating_team.abbrev}") @property @@ -117,6 +120,54 @@ class TradeBuilder: """Get total number of moves in trade.""" return self.trade.total_moves + @property + def all_teams_accepted(self) -> bool: + """Check if all participating teams have accepted the trade.""" + participating_ids = {team.id for team in self.participating_teams} + return participating_ids == self.accepted_teams + + @property + def pending_teams(self) -> List[Team]: + """Get list of teams that haven't accepted yet.""" + return [team for team in self.participating_teams if team.id not in self.accepted_teams] + + def accept_trade(self, team_id: int) -> bool: + """ + Record a team's acceptance of the trade. + + Args: + team_id: ID of the team accepting + + Returns: + True if all teams have now accepted, False otherwise + """ + self.accepted_teams.add(team_id) + logger.info(f"Team {team_id} accepted trade {self.trade_id}. Accepted: {len(self.accepted_teams)}/{self.team_count}") + return self.all_teams_accepted + + def reject_trade(self) -> None: + """ + Reject the trade, moving it back to DRAFT status. + + Clears all acceptances so teams can renegotiate. + """ + self.accepted_teams.clear() + self.trade.status = TradeStatus.DRAFT + logger.info(f"Trade {self.trade_id} rejected and moved back to DRAFT") + + def get_acceptance_status(self) -> Dict[int, bool]: + """ + Get acceptance status for each participating team. + + Returns: + Dict mapping team_id to acceptance status (True/False) + """ + return {team.id: team.id in self.accepted_teams for team in self.participating_teams} + + def has_team_accepted(self, team_id: int) -> bool: + """Check if a specific team has accepted.""" + return team_id in self.accepted_teams + async def add_team(self, team: Team) -> tuple[bool, str]: """ Add a team to the trade. diff --git a/tests/test_services_trade_builder.py b/tests/test_services_trade_builder.py index 8c0eec0..819e177 100644 --- a/tests/test_services_trade_builder.py +++ b/tests/test_services_trade_builder.py @@ -499,6 +499,248 @@ class TestTradeBuilder: assert "WV" in summary and "NY" in summary +class TestTradeAcceptance: + """ + Test trade acceptance tracking functionality. + + The acceptance system allows multi-GM approval of trades before they are finalized. + Each participating team's GM must accept the trade before it can be converted to + transactions and posted to the database. + """ + + def setup_method(self): + """Set up test fixtures.""" + self.user_id = 12345 + self.team1 = TeamFactory.west_virginia() + self.team2 = TeamFactory.new_york() + self.team3 = TeamFactory.create(id=3, abbrev="BOS", sname="Red Sox") + + # Clear any existing trade builders + _active_trade_builders.clear() + + def test_initial_acceptance_state(self): + """ + Test that a new trade has no acceptances. + + When a trade is first created, no teams should be marked as accepted + since acceptance happens after the trade is submitted for approval. + """ + builder = TradeBuilder(self.user_id, self.team1, season=12) + + assert builder.accepted_teams == set() + assert not builder.all_teams_accepted + assert builder.pending_teams == [self.team1] + assert not builder.has_team_accepted(self.team1.id) + + def test_accept_trade_single_team(self): + """ + Test single team acceptance. + + When only one team is in the trade and accepts, all_teams_accepted + should return True since all participating teams (just 1) have accepted. + """ + builder = TradeBuilder(self.user_id, self.team1, season=12) + + # Accept trade as team1 + all_accepted = builder.accept_trade(self.team1.id) + + assert all_accepted # Only one team, so should be True + assert builder.has_team_accepted(self.team1.id) + assert builder.all_teams_accepted + assert builder.pending_teams == [] + + @pytest.mark.asyncio + async def test_accept_trade_two_teams(self): + """ + Test acceptance workflow with two teams. + + Both teams must accept before all_teams_accepted returns True. + This tests the core multi-GM acceptance requirement. + """ + builder = TradeBuilder(self.user_id, self.team1, season=12) + await builder.add_team(self.team2) + + # Team1 accepts first + all_accepted = builder.accept_trade(self.team1.id) + assert not all_accepted # Team2 hasn't accepted yet + assert builder.has_team_accepted(self.team1.id) + assert not builder.has_team_accepted(self.team2.id) + assert builder.pending_teams == [self.team2] + + # Team2 accepts second + all_accepted = builder.accept_trade(self.team2.id) + assert all_accepted # Now all teams have accepted + assert builder.has_team_accepted(self.team2.id) + assert builder.all_teams_accepted + assert builder.pending_teams == [] + + @pytest.mark.asyncio + async def test_accept_trade_three_teams(self): + """ + Test acceptance workflow with three teams (multi-team trade). + + Multi-team trades require all participating teams to accept. + This validates proper handling of 3+ team trades. + """ + builder = TradeBuilder(self.user_id, self.team1, season=12) + await builder.add_team(self.team2) + await builder.add_team(self.team3) + + # First team accepts + all_accepted = builder.accept_trade(self.team1.id) + assert not all_accepted + assert len(builder.pending_teams) == 2 + + # Second team accepts + all_accepted = builder.accept_trade(self.team2.id) + assert not all_accepted + assert len(builder.pending_teams) == 1 + + # Third team accepts + all_accepted = builder.accept_trade(self.team3.id) + assert all_accepted + assert len(builder.pending_teams) == 0 + + @pytest.mark.asyncio + async def test_reject_trade_clears_acceptances(self): + """ + Test that rejecting a trade clears all acceptances. + + When any team rejects, the trade goes back to DRAFT status and + all previous acceptances are cleared so teams can renegotiate. + """ + builder = TradeBuilder(self.user_id, self.team1, season=12) + await builder.add_team(self.team2) + + # Both teams accept + builder.accept_trade(self.team1.id) + builder.accept_trade(self.team2.id) + assert builder.all_teams_accepted + + # Reject trade + builder.reject_trade() + + # All acceptances should be cleared + assert builder.accepted_teams == set() + assert not builder.all_teams_accepted + assert not builder.has_team_accepted(self.team1.id) + assert not builder.has_team_accepted(self.team2.id) + assert len(builder.pending_teams) == 2 + + @pytest.mark.asyncio + async def test_reject_trade_changes_status_to_draft(self): + """ + Test that rejecting a trade moves status back to DRAFT. + + DRAFT status allows teams to continue modifying the trade + before re-submitting for approval. + """ + builder = TradeBuilder(self.user_id, self.team1, season=12) + await builder.add_team(self.team2) + + # Change status to PROPOSED (simulating submission) + builder.trade.status = TradeStatus.PROPOSED + assert builder.trade.status == TradeStatus.PROPOSED + + # Reject trade + builder.reject_trade() + + # Status should be back to DRAFT + assert builder.trade.status == TradeStatus.DRAFT + + @pytest.mark.asyncio + async def test_get_acceptance_status(self): + """ + Test getting acceptance status for all teams. + + The get_acceptance_status method returns a dictionary mapping + each team's ID to their acceptance status (True/False). + """ + builder = TradeBuilder(self.user_id, self.team1, season=12) + await builder.add_team(self.team2) + + # Initial status - both False + status = builder.get_acceptance_status() + assert status == {self.team1.id: False, self.team2.id: False} + + # After team1 accepts + builder.accept_trade(self.team1.id) + status = builder.get_acceptance_status() + assert status == {self.team1.id: True, self.team2.id: False} + + # After both accept + builder.accept_trade(self.team2.id) + status = builder.get_acceptance_status() + assert status == {self.team1.id: True, self.team2.id: True} + + @pytest.mark.asyncio + async def test_duplicate_acceptance_is_idempotent(self): + """ + Test that accepting twice has no adverse effects. + + GMs might click the Accept button multiple times. The system should + handle this gracefully by treating it as idempotent. + """ + builder = TradeBuilder(self.user_id, self.team1, season=12) + await builder.add_team(self.team2) + + # Team1 accepts once + builder.accept_trade(self.team1.id) + assert len(builder.accepted_teams) == 1 + + # Team1 accepts again (idempotent) + builder.accept_trade(self.team1.id) + assert len(builder.accepted_teams) == 1 # Still just 1 + assert builder.has_team_accepted(self.team1.id) + + @pytest.mark.asyncio + async def test_pending_teams_returns_correct_order(self): + """ + Test that pending_teams returns teams in participation order. + + The order of pending teams should match the order they were added + to the trade for consistent display in the UI. + """ + builder = TradeBuilder(self.user_id, self.team1, season=12) + await builder.add_team(self.team2) + await builder.add_team(self.team3) + + # All teams pending initially + pending = builder.pending_teams + assert len(pending) == 3 + assert pending[0].id == self.team1.id + assert pending[1].id == self.team2.id + assert pending[2].id == self.team3.id + + # After middle team accepts, order preserved for remaining + builder.accept_trade(self.team2.id) + pending = builder.pending_teams + assert len(pending) == 2 + assert pending[0].id == self.team1.id + assert pending[1].id == self.team3.id + + @pytest.mark.asyncio + async def test_acceptance_survives_trade_modifications(self): + """ + Test that acceptances are independent of trade move changes. + + Note: In real usage, the UI should prevent modifications after + submission, but the data model doesn't enforce this coupling. + """ + builder = TradeBuilder(self.user_id, self.team1, season=12) + await builder.add_team(self.team2) + + # Team1 accepts + builder.accept_trade(self.team1.id) + assert builder.has_team_accepted(self.team1.id) + + # Clear trade moves (would require UI re-submission in real use) + builder.clear_trade() + + # Acceptance is still recorded (UI should handle re-submission flow) + assert builder.has_team_accepted(self.team1.id) + + class TestTradeBuilderCache: """Test trade builder cache functionality.""" diff --git a/utils/transaction_logging.py b/utils/transaction_logging.py index 77c8a02..a0e958d 100644 --- a/utils/transaction_logging.py +++ b/utils/transaction_logging.py @@ -4,12 +4,14 @@ Transaction Logging Utility Provides centralized function for posting transaction notifications to the #transaction-log channel. """ -from typing import List, Optional +from typing import List, Optional, Dict import discord from config import get_config from models.transaction import Transaction from models.team import Team +from models.trade import Trade +from services.trade_builder import TradeBuilder from views.embeds import EmbedTemplate, EmbedColors from utils.logging import get_contextual_logger @@ -142,3 +144,117 @@ async def _determine_team_from_transactions(transactions: List[Transaction]) -> # Default to newteam (both are FA) return first_move.newteam + + +async def post_trade_to_log( + bot: discord.Client, + builder: TradeBuilder, + transactions: List[Transaction], + effective_week: int +) -> bool: + """ + Post a completed trade to the #transaction-log channel. + + Creates a rich embed showing all teams involved and player movements + in a clear, organized format. + + Args: + bot: Discord bot instance + builder: TradeBuilder with trade details + transactions: List of created Transaction objects + effective_week: Week the trade takes effect + + Returns: + True if posted successfully, False otherwise + """ + try: + if not transactions: + logger.warning("No transactions provided to post_trade_to_log") + return False + + # Get guild and channel + config = get_config() + guild = bot.get_guild(config.guild_id) + if not guild: + logger.warning(f"Could not find guild {config.guild_id}") + return False + + channel = discord.utils.get(guild.text_channels, name='transaction-log') + if not channel: + logger.warning("Could not find #transaction-log channel") + return False + + # Get participating teams + teams = builder.participating_teams + team_abbrevs = " ↔ ".join(t.abbrev for t in teams) + + # Create the trade embed + embed = EmbedTemplate.create_base_embed( + title=f"šŸ¤ Trade Complete: {team_abbrevs}", + description=f"**Week {effective_week}** • Season {builder.trade.season}", + color=EmbedColors.SUCCESS + ) + + # Group transactions by receiving team (who's getting who) + moves_by_receiver: Dict[str, List[str]] = {} + for txn in transactions: + # Get the ML affiliate for proper team naming + try: + receiving_team = await txn.newteam.major_league_affiliate() + receiving_abbrev = receiving_team.abbrev + except Exception: + receiving_abbrev = txn.newteam.abbrev + + if receiving_abbrev not in moves_by_receiver: + moves_by_receiver[receiving_abbrev] = [] + + # Format: PlayerName (sWAR) from OLDTEAM + try: + sending_team = await txn.oldteam.major_league_affiliate() + sending_abbrev = sending_team.abbrev + except Exception: + sending_abbrev = txn.oldteam.abbrev + + moves_by_receiver[receiving_abbrev].append( + f"**{txn.player.name}** ({txn.player.wara:.1f}) from {sending_abbrev}" + ) + + # Add a field for each team receiving players + for team_abbrev, moves in moves_by_receiver.items(): + # Find the team object for potential thumbnail + team_obj = next((t for t in teams if t.abbrev == team_abbrev), None) + team_name = team_obj.sname if team_obj else team_abbrev + + embed.add_field( + name=f"šŸ“„ {team_name} receives:", + value="\n".join(moves), + inline=False + ) + + # Set thumbnail to first team's logo (or could alternate) + primary_team = teams[0] if teams else None + if primary_team and hasattr(primary_team, 'thumbnail') and primary_team.thumbnail: + embed.set_thumbnail(url=primary_team.thumbnail) + + # Set team color from first team + if primary_team and hasattr(primary_team, 'color') and primary_team.color: + try: + color_hex = primary_team.color.replace('#', '') + embed.color = discord.Color(int(color_hex, 16)) + except (ValueError, AttributeError): + pass + + # Add footer with trade ID and SBA branding + embed.set_footer( + text=f"Trade ID: {builder.trade_id} • SBA Season {builder.trade.season}", + icon_url="https://sombaseball.ddns.net/static/images/sba-logo.png" + ) + + # Post to channel + await channel.send(embed=embed) + logger.info(f"Trade posted to log: {builder.trade_id}, {len(transactions)} moves, {len(teams)} teams") + return True + + except Exception as e: + logger.error(f"Error posting trade to log: {e}") + return False diff --git a/views/trade_embed.py b/views/trade_embed.py index e52c435..cec7182 100644 --- a/views/trade_embed.py +++ b/views/trade_embed.py @@ -5,9 +5,10 @@ Handles the Discord embed and button interfaces for the multi-team trade builder """ import discord from typing import Optional, List -from datetime import datetime +from datetime import datetime, timezone from services.trade_builder import TradeBuilder, TradeValidationResult +from models.team import Team, RosterType from views.embeds import EmbedColors, EmbedTemplate @@ -258,14 +259,15 @@ class RemoveTradeMovesSelect(discord.ui.Select): class SubmitTradeConfirmationModal(discord.ui.Modal): - """Modal for confirming trade submission.""" + """Modal for confirming trade submission - posts acceptance request to trade channel.""" - def __init__(self, builder: TradeBuilder): + def __init__(self, builder: TradeBuilder, trade_channel: Optional[discord.TextChannel] = None): super().__init__(title="Confirm Trade Submission") self.builder = builder + self.trade_channel = trade_channel self.confirmation = discord.ui.TextInput( - label="Type 'CONFIRM' to submit", + label="Type 'CONFIRM' to submit for approval", placeholder="CONFIRM", required=True, max_length=7 @@ -274,7 +276,7 @@ class SubmitTradeConfirmationModal(discord.ui.Modal): self.add_item(self.confirmation) async def on_submit(self, interaction: discord.Interaction): - """Handle confirmation submission.""" + """Handle confirmation submission - posts acceptance view to trade channel.""" if self.confirmation.value.upper() != "CONFIRM": await interaction.response.send_message( "āŒ Trade not submitted. You must type 'CONFIRM' exactly.", @@ -285,56 +287,43 @@ class SubmitTradeConfirmationModal(discord.ui.Modal): await interaction.response.defer(ephemeral=True) try: - # For now, just show success message since actual submission - # would require integration with the transaction processing system + # Update trade status to PROPOSED + from models.trade import TradeStatus + self.builder.trade.status = TradeStatus.PROPOSED - # Create success message - success_msg = f"āœ… **Trade Submitted Successfully!**\n\n" - success_msg += f"**Trade ID:** `{self.builder.trade_id}`\n" - success_msg += f"**Teams:** {self.builder.trade.get_trade_summary()}\n" - success_msg += f"**Total Moves:** {self.builder.move_count}\n\n" + # Create acceptance embed and view + acceptance_embed = await create_trade_acceptance_embed(self.builder) + acceptance_view = TradeAcceptanceView(self.builder) - success_msg += "**Trade Details:**\n" + # Find the trade channel to post to + channel = self.trade_channel + if not channel: + # Try to find trade channel by name pattern + trade_channel_name = f"trade-{'-'.join(t.abbrev.lower() for t in self.builder.participating_teams)}" + for ch in interaction.guild.text_channels: # type: ignore + if ch.name.startswith("trade-") and self.builder.trade_id[:4] in ch.name: + channel = ch + break - # Show cross-team moves - if self.builder.trade.cross_team_moves: - success_msg += "**Player Exchanges:**\n" - for move in self.builder.trade.cross_team_moves: - success_msg += f"• {move.description}\n" - - # Show supplementary moves - if self.builder.trade.supplementary_moves: - success_msg += "\n**Supplementary Moves:**\n" - for move in self.builder.trade.supplementary_moves: - success_msg += f"• {move.description}\n" - - success_msg += f"\nšŸ’” Use `/trade view` to check trade status" - - await interaction.followup.send(success_msg, ephemeral=True) - - # Clear the builder after successful submission - from services.trade_builder import clear_trade_builder - clear_trade_builder(interaction.user.id) - - # Update the original embed to show completion - completion_embed = discord.Embed( - title="āœ… Trade Submitted", - description=f"Your trade has been submitted successfully!\n\nTrade ID: `{self.builder.trade_id}`", - 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): # type: ignore - if message.author == interaction.client.user and message.embeds: - if "Trade Builder" in message.embeds[0].title: # type: ignore - await message.edit(embed=completion_embed, view=view) - break - except: - pass + if channel: + # Post acceptance request to trade channel + await channel.send( + content="šŸ“‹ **Trade submitted for approval!** All teams must accept to complete the trade.", + embed=acceptance_embed, + view=acceptance_view + ) + await interaction.followup.send( + f"āœ… Trade submitted for approval!\n\nThe acceptance request has been posted to {channel.mention}.\n" + f"All participating teams must click **Accept Trade** to finalize.", + ephemeral=True + ) + else: + # No trade channel found, post in current channel + await interaction.followup.send( + content="šŸ“‹ **Trade submitted for approval!** All teams must accept to complete the trade.", + embed=acceptance_embed, + view=acceptance_view + ) except Exception as e: await interaction.followup.send( @@ -343,6 +332,382 @@ class SubmitTradeConfirmationModal(discord.ui.Modal): ) +class TradeAcceptanceView(discord.ui.View): + """View for accepting or rejecting a proposed trade.""" + + def __init__(self, builder: TradeBuilder): + super().__init__(timeout=3600.0) # 1 hour timeout + self.builder = builder + + async def _get_user_team(self, interaction: discord.Interaction) -> Optional[Team]: + """Get the team owned by the interacting user.""" + from services.team_service import team_service + from config import get_config + config = get_config() + return await team_service.get_team_by_owner(interaction.user.id, config.sba_season) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + """Check if user is a GM of a participating team.""" + user_team = await self._get_user_team(interaction) + + if not user_team: + await interaction.response.send_message( + "āŒ You don't own a team in the league.", + ephemeral=True + ) + return False + + # Check if their team (or organization) is participating + participant = self.builder.trade.get_participant_by_organization(user_team) + if not participant: + await interaction.response.send_message( + "āŒ Your team is not part of this trade.", + ephemeral=True + ) + return False + + return True + + async def on_timeout(self) -> None: + """Handle view timeout - disable buttons but keep trade in memory.""" + for item in self.children: + if isinstance(item, discord.ui.Button): + item.disabled = True + + @discord.ui.button(label="Accept Trade", style=discord.ButtonStyle.success, emoji="āœ…") + async def accept_button(self, interaction: discord.Interaction, button: discord.ui.Button): + """Handle accept button click.""" + user_team = await self._get_user_team(interaction) + if not user_team: + return + + # Find the participating team (could be org affiliate) + participant = self.builder.trade.get_participant_by_organization(user_team) + if not participant: + return + + team_id = participant.team.id + + # Check if already accepted + if self.builder.has_team_accepted(team_id): + await interaction.response.send_message( + f"āœ… {participant.team.abbrev} has already accepted this trade.", + ephemeral=True + ) + return + + # Record acceptance + all_accepted = self.builder.accept_trade(team_id) + + if all_accepted: + # All teams accepted - finalize the trade + await self._finalize_trade(interaction) + else: + # Update embed to show new acceptance status + embed = await create_trade_acceptance_embed(self.builder) + await interaction.response.edit_message(embed=embed, view=self) + + # Send confirmation to channel + await interaction.followup.send( + f"āœ… **{participant.team.abbrev}** has accepted the trade! " + f"({len(self.builder.accepted_teams)}/{self.builder.team_count} teams)" + ) + + @discord.ui.button(label="Reject Trade", style=discord.ButtonStyle.danger, emoji="āŒ") + async def reject_button(self, interaction: discord.Interaction, button: discord.ui.Button): + """Handle reject button click - moves trade back to DRAFT.""" + user_team = await self._get_user_team(interaction) + if not user_team: + return + + participant = self.builder.trade.get_participant_by_organization(user_team) + if not participant: + return + + # Reject the trade + self.builder.reject_trade() + + # Disable buttons + self.accept_button.disabled = True + self.reject_button.disabled = True + + # Update embed to show rejection + embed = await create_trade_rejection_embed(self.builder, participant.team) + await interaction.response.edit_message(embed=embed, view=self) + + # Notify the channel + await interaction.followup.send( + f"āŒ **{participant.team.abbrev}** has rejected the trade.\n\n" + f"The trade has been moved back to **DRAFT** status. " + f"Teams can continue negotiating using `/trade` commands." + ) + + self.stop() + + async def _finalize_trade(self, interaction: discord.Interaction) -> None: + """Finalize the trade - create transactions and complete.""" + from services.league_service import league_service + from services.transaction_service import transaction_service + from services.trade_builder import clear_trade_builder_by_team + from models.transaction import Transaction + from models.trade import TradeStatus + from utils.transaction_logging import post_trade_to_log + from config import get_config + + try: + await interaction.response.defer() + + config = get_config() + + # Get next week for transactions + current = await league_service.get_current_state() + next_week = current.week + 1 if current else 1 + + # Create FA team for reference + fa_team = Team( + id=config.free_agent_team_id, + abbrev="FA", + sname="Free Agents", + lname="Free Agency", + season=self.builder.trade.season + ) # type: ignore + + # Create transactions from all moves + transactions: List[Transaction] = [] + move_id = f"Trade-{self.builder.trade_id}-{int(datetime.now(timezone.utc).timestamp())}" + + # Process cross-team moves + for move in self.builder.trade.cross_team_moves: + # Get actual team affiliates for from/to based on roster type + if move.from_roster == RosterType.MAJOR_LEAGUE: + old_team = move.source_team + elif move.from_roster == RosterType.MINOR_LEAGUE: + old_team = await move.source_team.minor_league_affiliate() if move.source_team else None + elif move.from_roster == RosterType.INJURED_LIST: + old_team = await move.source_team.injured_list_affiliate() if move.source_team else None + else: + old_team = move.source_team + + if move.to_roster == RosterType.MAJOR_LEAGUE: + new_team = move.destination_team + elif move.to_roster == RosterType.MINOR_LEAGUE: + new_team = await move.destination_team.minor_league_affiliate() if move.destination_team else None + elif move.to_roster == RosterType.INJURED_LIST: + new_team = await move.destination_team.injured_list_affiliate() if move.destination_team else None + else: + new_team = move.destination_team + + if old_team and new_team: + transaction = Transaction( + id=0, + week=next_week, + season=self.builder.trade.season, + moveid=move_id, + player=move.player, + oldteam=old_team, + newteam=new_team, + cancelled=False, + frozen=False # Trades are NOT frozen - immediately effective + ) + transactions.append(transaction) + + # Process supplementary moves + for move in self.builder.trade.supplementary_moves: + if move.from_roster == RosterType.MAJOR_LEAGUE: + old_team = move.source_team + elif move.from_roster == RosterType.MINOR_LEAGUE: + old_team = await move.source_team.minor_league_affiliate() if move.source_team else None + elif move.from_roster == RosterType.INJURED_LIST: + old_team = await move.source_team.injured_list_affiliate() if move.source_team else None + elif move.from_roster == RosterType.FREE_AGENCY: + old_team = fa_team + else: + old_team = move.source_team + + if move.to_roster == RosterType.MAJOR_LEAGUE: + new_team = move.destination_team + elif move.to_roster == RosterType.MINOR_LEAGUE: + new_team = await move.destination_team.minor_league_affiliate() if move.destination_team else None + elif move.to_roster == RosterType.INJURED_LIST: + new_team = await move.destination_team.injured_list_affiliate() if move.destination_team else None + elif move.to_roster == RosterType.FREE_AGENCY: + new_team = fa_team + else: + new_team = move.destination_team + + if old_team and new_team: + transaction = Transaction( + id=0, + week=next_week, + season=self.builder.trade.season, + moveid=move_id, + player=move.player, + oldteam=old_team, + newteam=new_team, + cancelled=False, + frozen=False # Trades are NOT frozen - immediately effective + ) + transactions.append(transaction) + + # POST transactions to database + if transactions: + created_transactions = await transaction_service.create_transaction_batch(transactions) + else: + created_transactions = [] + + # Post to #transaction-log channel + if created_transactions and interaction.client: + await post_trade_to_log( + bot=interaction.client, + builder=self.builder, + transactions=created_transactions, + effective_week=next_week + ) + + # Update trade status + self.builder.trade.status = TradeStatus.ACCEPTED + + # Disable buttons + self.accept_button.disabled = True + self.reject_button.disabled = True + + # Update embed to show completion + embed = await create_trade_complete_embed(self.builder, len(created_transactions), next_week) + await interaction.edit_original_response(embed=embed, view=self) + + # Send completion message + await interaction.followup.send( + f"šŸŽ‰ **Trade Complete!**\n\n" + f"All {self.builder.team_count} teams have accepted the trade.\n" + f"**{len(created_transactions)} transactions** have been created for **Week {next_week}**.\n\n" + f"Trade ID: `{self.builder.trade_id}`" + ) + + # Clear the trade builder + for team in self.builder.participating_teams: + clear_trade_builder_by_team(team.id) + + self.stop() + + except Exception as e: + await interaction.followup.send( + f"āŒ Error finalizing trade: {str(e)}", + ephemeral=True + ) + + +async def create_trade_acceptance_embed(builder: TradeBuilder) -> discord.Embed: + """Create embed showing trade details and acceptance status.""" + embed = EmbedTemplate.create_base_embed( + title=f"šŸ“‹ Trade Pending Acceptance - {builder.trade.get_trade_summary()}", + description="All participating teams must accept to complete the trade.", + color=EmbedColors.WARNING + ) + + # Show participating teams + team_list = [f"• {team.abbrev} - {team.sname}" for team in builder.participating_teams] + embed.add_field( + name=f"šŸŸļø Participating Teams ({builder.team_count})", + value="\n".join(team_list), + inline=False + ) + + # Show cross-team moves + if builder.trade.cross_team_moves: + moves_text = "" + for move in builder.trade.cross_team_moves[:10]: + moves_text += f"• {move.description}\n" + if len(builder.trade.cross_team_moves) > 10: + moves_text += f"... and {len(builder.trade.cross_team_moves) - 10} more" + embed.add_field( + name=f"šŸ”„ Player Exchanges ({len(builder.trade.cross_team_moves)})", + value=moves_text, + inline=False + ) + + # Show supplementary moves if any + if builder.trade.supplementary_moves: + supp_text = "" + for move in builder.trade.supplementary_moves[:5]: + supp_text += f"• {move.description}\n" + if len(builder.trade.supplementary_moves) > 5: + supp_text += f"... and {len(builder.trade.supplementary_moves) - 5} more" + embed.add_field( + name=f"āš™ļø Supplementary Moves ({len(builder.trade.supplementary_moves)})", + value=supp_text, + inline=False + ) + + # Show acceptance status + status_lines = [] + for team in builder.participating_teams: + if team.id in builder.accepted_teams: + status_lines.append(f"āœ… **{team.abbrev}** - Accepted") + else: + status_lines.append(f"ā³ **{team.abbrev}** - Pending") + + embed.add_field( + name="šŸ“Š Acceptance Status", + value="\n".join(status_lines), + inline=False + ) + + # Add footer + embed.set_footer(text=f"Trade ID: {builder.trade_id} • {len(builder.accepted_teams)}/{builder.team_count} teams accepted") + + return embed + + +async def create_trade_rejection_embed(builder: TradeBuilder, rejecting_team: Team) -> discord.Embed: + """Create embed showing trade was rejected.""" + embed = EmbedTemplate.create_base_embed( + title=f"āŒ Trade Rejected - {builder.trade.get_trade_summary()}", + description=f"**{rejecting_team.abbrev}** has rejected the trade.\n\n" + f"The trade has been moved back to **DRAFT** status.\n" + f"Teams can continue negotiating using `/trade` commands.", + color=EmbedColors.ERROR + ) + + embed.set_footer(text=f"Trade ID: {builder.trade_id}") + + return embed + + +async def create_trade_complete_embed(builder: TradeBuilder, transaction_count: int, effective_week: int) -> discord.Embed: + """Create embed showing trade was completed.""" + embed = EmbedTemplate.create_base_embed( + title=f"šŸŽ‰ Trade Complete! - {builder.trade.get_trade_summary()}", + description=f"All {builder.team_count} teams have accepted the trade!\n\n" + f"**{transaction_count} transactions** created for **Week {effective_week}**.", + color=EmbedColors.SUCCESS + ) + + # Show final acceptance status (all green) + status_lines = [f"āœ… **{team.abbrev}** - Accepted" for team in builder.participating_teams] + embed.add_field( + name="šŸ“Š Final Status", + value="\n".join(status_lines), + inline=False + ) + + # Show cross-team moves + if builder.trade.cross_team_moves: + moves_text = "" + for move in builder.trade.cross_team_moves[:8]: + moves_text += f"• {move.description}\n" + if len(builder.trade.cross_team_moves) > 8: + moves_text += f"... and {len(builder.trade.cross_team_moves) - 8} more" + embed.add_field( + name=f"šŸ”„ Player Exchanges", + value=moves_text, + inline=False + ) + + embed.set_footer(text=f"Trade ID: {builder.trade_id} • Effective: Week {effective_week}") + + return embed + + async def create_trade_embed(builder: TradeBuilder) -> discord.Embed: """ Create the main trade builder embed.