Add trade acceptance workflow with transaction logging (v2.22.0)

- Add multi-team trade acceptance system requiring all GMs to approve
- TradeBuilder tracks accepted_teams with accept_trade/reject_trade methods
- TradeAcceptanceView with Accept/Reject buttons validates GM permissions
- Create transactions when all teams accept (frozen=false for immediate effect)
- Add post_trade_to_log() for rich trade embeds in #transaction-log
- Trade embeds show grouped player moves by receiving team with sWAR
- Add 10 comprehensive tests for acceptance tracking methods
- All 36 trade builder tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-04 13:46:10 -06:00
parent 55c56bee76
commit b8b55e90a3
7 changed files with 858 additions and 61 deletions

View File

@ -513,7 +513,30 @@ jq 'select(.extra.duration_ms > 5000)' logs/discord_bot_v2.json
- [ ] Added tests following established patterns - [ ] Added tests following established patterns
- [ ] Verified all tests pass - [ ] 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) ### Custom Help Commands System (January 2025)
**Comprehensive admin-managed help system for league documentation**: **Comprehensive admin-managed help system for league documentation**:

View File

@ -1 +1 @@
2.21.0 2.22.0

View File

@ -183,7 +183,7 @@ class TradeCommands(commands.Cog):
other_team: str other_team: str
): ):
"""Add a team to an existing trade.""" """Add a team to an existing trade."""
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=False)
# Get user's team first # Get user's team first
user_team = await validate_user_has_team(interaction) user_team = await validate_user_has_team(interaction)
@ -266,7 +266,7 @@ class TradeCommands(commands.Cog):
destination_team: str destination_team: str
): ):
"""Add a player move to the trade.""" """Add a player move to the trade."""
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=False)
# Get user's team first # Get user's team first
user_team = await validate_user_has_team(interaction) user_team = await validate_user_has_team(interaction)
@ -373,7 +373,7 @@ class TradeCommands(commands.Cog):
destination: str destination: str
): ):
"""Add a supplementary (internal organization) move for roster legality.""" """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 # Get user's team first
user_team = await validate_user_has_team(interaction) user_team = await validate_user_has_team(interaction)
@ -464,7 +464,7 @@ class TradeCommands(commands.Cog):
@logged_command("/trade view") @logged_command("/trade view")
async def trade_view(self, interaction: discord.Interaction): async def trade_view(self, interaction: discord.Interaction):
"""View the current trade.""" """View the current trade."""
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=False)
# Get user's team first # Get user's team first
user_team = await validate_user_has_team(interaction) user_team = await validate_user_has_team(interaction)
@ -506,7 +506,7 @@ class TradeCommands(commands.Cog):
@logged_command("/trade clear") @logged_command("/trade clear")
async def trade_clear(self, interaction: discord.Interaction): async def trade_clear(self, interaction: discord.Interaction):
"""Clear the current trade.""" """Clear the current trade."""
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=False)
# Get user's team first # Get user's team first
user_team = await validate_user_has_team(interaction) user_team = await validate_user_has_team(interaction)

View File

@ -4,7 +4,7 @@ Trade Builder Service
Extends the TransactionBuilder to support multi-team trades and player exchanges. Extends the TransactionBuilder to support multi-team trades and player exchanges.
""" """
import logging import logging
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Set, Tuple
from datetime import datetime, timezone from datetime import datetime, timezone
import uuid import uuid
@ -90,6 +90,9 @@ class TradeBuilder:
# Cache transaction builders for each participating team # Cache transaction builders for each participating team
self._team_builders: Dict[int, TransactionBuilder] = {} 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}") logger.info(f"TradeBuilder initialized: {self.trade.trade_id} by user {initiated_by} for {initiating_team.abbrev}")
@property @property
@ -117,6 +120,54 @@ class TradeBuilder:
"""Get total number of moves in trade.""" """Get total number of moves in trade."""
return self.trade.total_moves 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]: async def add_team(self, team: Team) -> tuple[bool, str]:
""" """
Add a team to the trade. Add a team to the trade.

View File

@ -499,6 +499,248 @@ class TestTradeBuilder:
assert "WV" in summary and "NY" in summary 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: class TestTradeBuilderCache:
"""Test trade builder cache functionality.""" """Test trade builder cache functionality."""

View File

@ -4,12 +4,14 @@ Transaction Logging Utility
Provides centralized function for posting transaction notifications Provides centralized function for posting transaction notifications
to the #transaction-log channel. to the #transaction-log channel.
""" """
from typing import List, Optional from typing import List, Optional, Dict
import discord import discord
from config import get_config from config import get_config
from models.transaction import Transaction from models.transaction import Transaction
from models.team import Team from models.team import Team
from models.trade import Trade
from services.trade_builder import TradeBuilder
from views.embeds import EmbedTemplate, EmbedColors from views.embeds import EmbedTemplate, EmbedColors
from utils.logging import get_contextual_logger 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) # Default to newteam (both are FA)
return first_move.newteam 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

View File

@ -5,9 +5,10 @@ Handles the Discord embed and button interfaces for the multi-team trade builder
""" """
import discord import discord
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime, timezone
from services.trade_builder import TradeBuilder, TradeValidationResult from services.trade_builder import TradeBuilder, TradeValidationResult
from models.team import Team, RosterType
from views.embeds import EmbedColors, EmbedTemplate from views.embeds import EmbedColors, EmbedTemplate
@ -258,14 +259,15 @@ class RemoveTradeMovesSelect(discord.ui.Select):
class SubmitTradeConfirmationModal(discord.ui.Modal): 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") super().__init__(title="Confirm Trade Submission")
self.builder = builder self.builder = builder
self.trade_channel = trade_channel
self.confirmation = discord.ui.TextInput( self.confirmation = discord.ui.TextInput(
label="Type 'CONFIRM' to submit", label="Type 'CONFIRM' to submit for approval",
placeholder="CONFIRM", placeholder="CONFIRM",
required=True, required=True,
max_length=7 max_length=7
@ -274,7 +276,7 @@ class SubmitTradeConfirmationModal(discord.ui.Modal):
self.add_item(self.confirmation) self.add_item(self.confirmation)
async def on_submit(self, interaction: discord.Interaction): 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": if self.confirmation.value.upper() != "CONFIRM":
await interaction.response.send_message( await interaction.response.send_message(
"❌ Trade not submitted. You must type 'CONFIRM' exactly.", "❌ Trade not submitted. You must type 'CONFIRM' exactly.",
@ -285,56 +287,43 @@ class SubmitTradeConfirmationModal(discord.ui.Modal):
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=True)
try: try:
# For now, just show success message since actual submission # Update trade status to PROPOSED
# would require integration with the transaction processing system from models.trade import TradeStatus
self.builder.trade.status = TradeStatus.PROPOSED
# Create success message # Create acceptance embed and view
success_msg = f"✅ **Trade Submitted Successfully!**\n\n" acceptance_embed = await create_trade_acceptance_embed(self.builder)
success_msg += f"**Trade ID:** `{self.builder.trade_id}`\n" acceptance_view = TradeAcceptanceView(self.builder)
success_msg += f"**Teams:** {self.builder.trade.get_trade_summary()}\n"
success_msg += f"**Total Moves:** {self.builder.move_count}\n\n"
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 channel:
if self.builder.trade.cross_team_moves: # Post acceptance request to trade channel
success_msg += "**Player Exchanges:**\n" await channel.send(
for move in self.builder.trade.cross_team_moves: content="📋 **Trade submitted for approval!** All teams must accept to complete the trade.",
success_msg += f"{move.description}\n" embed=acceptance_embed,
view=acceptance_view
# Show supplementary moves )
if self.builder.trade.supplementary_moves: await interaction.followup.send(
success_msg += "\n**Supplementary Moves:**\n" f"✅ Trade submitted for approval!\n\nThe acceptance request has been posted to {channel.mention}.\n"
for move in self.builder.trade.supplementary_moves: f"All participating teams must click **Accept Trade** to finalize.",
success_msg += f"{move.description}\n" ephemeral=True
)
success_msg += f"\n💡 Use `/trade view` to check trade status" else:
# No trade channel found, post in current channel
await interaction.followup.send(success_msg, ephemeral=True) await interaction.followup.send(
content="📋 **Trade submitted for approval!** All teams must accept to complete the trade.",
# Clear the builder after successful submission embed=acceptance_embed,
from services.trade_builder import clear_trade_builder view=acceptance_view
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
except Exception as e: except Exception as e:
await interaction.followup.send( 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: async def create_trade_embed(builder: TradeBuilder) -> discord.Embed:
""" """
Create the main trade builder embed. Create the main trade builder embed.