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
- [ ] 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**:

View File

@ -1 +1 @@
2.21.0
2.22.0

View File

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

View File

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

View File

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

View File

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

View File

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