major-domo-v2/views/trade_embed.py
Cal Corum b872a05397 feat: enforce trade deadline in /trade commands
Add is_past_trade_deadline property to Current model and guard /trade initiate,
submit, and finalize flows. All checks fail-closed (block if API unreachable).
981 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:39:04 -05:00

819 lines
30 KiB
Python

"""
Interactive Trade Embed Views
Handles the Discord embed and button interfaces for the multi-team trade builder.
"""
import discord
from typing import Optional, List
from datetime import datetime, timezone
from services.trade_builder import TradeBuilder, clear_trade_builder_by_team
from services.team_service import team_service
from services.league_service import league_service
from services.transaction_service import transaction_service
from models.team import Team, RosterType
from models.trade import TradeStatus
from models.transaction import Transaction
from views.embeds import EmbedColors, EmbedTemplate
from utils.transaction_logging import post_trade_to_log
from config import get_config
class TradeEmbedView(discord.ui.View):
"""Interactive view for the trade builder embed."""
def __init__(self, builder: TradeBuilder, user_id: int):
"""
Initialize the trade embed view.
Args:
builder: TradeBuilder instance
user_id: Discord user ID (for permission checking)
"""
super().__init__(timeout=900.0) # 15 minute timeout
self.builder = builder
self.user_id = user_id
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check if user has permission to interact with this view."""
if interaction.user.id != self.user_id:
await interaction.response.send_message(
"You don't have permission to use this trade builder.",
ephemeral=True,
)
return False
return True
async def on_timeout(self) -> None:
"""Handle view timeout."""
for item in self.children:
if isinstance(item, discord.ui.Button):
item.disabled = True
@discord.ui.button(label="Remove Move", style=discord.ButtonStyle.red)
async def remove_move_button(
self, interaction: discord.Interaction, button: discord.ui.Button
):
"""Handle remove move button click."""
if self.builder.is_empty:
await interaction.response.send_message(
"No moves to remove. Add some moves first!", ephemeral=True
)
return
select_view = RemoveTradeMovesView(self.builder, self.user_id)
embed = await create_trade_embed(self.builder)
await interaction.response.edit_message(embed=embed, view=select_view)
@discord.ui.button(label="Validate Trade", style=discord.ButtonStyle.secondary)
async def validate_button(
self, interaction: discord.Interaction, button: discord.ui.Button
):
"""Handle validate trade button click."""
await interaction.response.defer(ephemeral=True)
validation = await self.builder.validate_trade()
if validation.is_legal:
status_text = "**Trade is LEGAL**"
color = EmbedColors.SUCCESS
else:
status_text = "**Trade has ERRORS**"
color = EmbedColors.ERROR
embed = EmbedTemplate.create_base_embed(
title="Trade Validation Report",
description=status_text,
color=color,
)
for participant in self.builder.trade.participants:
team_validation = validation.get_participant_validation(participant.team.id)
if team_validation:
team_status = []
team_status.append(team_validation.major_league_status)
team_status.append(team_validation.minor_league_status)
team_status.append(team_validation.major_league_swar_status)
team_status.append(team_validation.minor_league_swar_status)
if team_validation.pre_existing_transactions_note:
team_status.append(team_validation.pre_existing_transactions_note)
embed.add_field(
name=f"{participant.team.abbrev} - {participant.team.sname}",
value="\n".join(team_status),
inline=False,
)
if validation.all_errors:
error_text = "\n".join([f"- {error}" for error in validation.all_errors])
embed.add_field(name="Errors", value=error_text, inline=False)
if validation.all_suggestions:
suggestion_text = "\n".join(
[f"- {suggestion}" for suggestion in validation.all_suggestions]
)
embed.add_field(name="Suggestions", value=suggestion_text, inline=False)
await interaction.followup.send(embed=embed, ephemeral=True)
@discord.ui.button(label="Submit Trade", style=discord.ButtonStyle.primary)
async def submit_button(
self, interaction: discord.Interaction, button: discord.ui.Button
):
"""Handle submit trade button click."""
# Check trade deadline
current = await league_service.get_current_state()
if not current:
await interaction.response.send_message(
"❌ Could not retrieve league state. Please try again later.",
ephemeral=True,
)
return
if current.is_past_trade_deadline:
await interaction.response.send_message(
f"❌ **The trade deadline has passed** (Week {current.trade_deadline}). "
f"This trade can no longer be submitted.",
ephemeral=True,
)
return
if self.builder.is_empty:
await interaction.response.send_message(
"Cannot submit empty trade. Add some moves first!", ephemeral=True
)
return
validation = await self.builder.validate_trade()
if not validation.is_legal:
error_msg = "**Cannot submit illegal trade:**\n"
error_msg += "\n".join([f"- {error}" for error in validation.all_errors])
if validation.all_suggestions:
error_msg += "\n\n**Suggestions:**\n"
error_msg += "\n".join(
[f"- {suggestion}" for suggestion in validation.all_suggestions]
)
await interaction.response.send_message(error_msg, ephemeral=True)
return
modal = SubmitTradeConfirmationModal(self.builder)
await interaction.response.send_modal(modal)
@discord.ui.button(label="Cancel Trade", style=discord.ButtonStyle.secondary)
async def cancel_button(
self, interaction: discord.Interaction, button: discord.ui.Button
):
"""Handle cancel trade button click."""
self.builder.clear_trade()
embed = await create_trade_embed(self.builder)
for item in self.children:
if isinstance(item, discord.ui.Button):
item.disabled = True
await interaction.response.edit_message(
content="**Trade cancelled and cleared.**", embed=embed, view=self
)
self.stop()
class RemoveTradeMovesView(discord.ui.View):
"""View for selecting which trade move to remove."""
def __init__(self, builder: TradeBuilder, user_id: int):
super().__init__(timeout=300.0) # 5 minute timeout
self.builder = builder
self.user_id = user_id
if not builder.is_empty:
self.add_item(RemoveTradeMovesSelect(builder))
back_button = discord.ui.Button(
label="Back", style=discord.ButtonStyle.secondary
)
back_button.callback = self.back_callback
self.add_item(back_button)
async def back_callback(self, interaction: discord.Interaction):
"""Handle back button to return to main view."""
main_view = TradeEmbedView(self.builder, self.user_id)
embed = await create_trade_embed(self.builder)
await interaction.response.edit_message(embed=embed, view=main_view)
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check if user has permission to interact with this view."""
return interaction.user.id == self.user_id
class RemoveTradeMovesSelect(discord.ui.Select):
"""Select menu for choosing which trade move to remove."""
def __init__(self, builder: TradeBuilder):
self.builder = builder
options = []
move_count = 0
for move in builder.trade.cross_team_moves[
:20
]: # Limit to avoid Discord's 25 option limit
options.append(
discord.SelectOption(
label=f"{move.player.name}",
description=move.description[:100],
value=str(move.player.id),
)
)
move_count += 1
remaining_slots = 25 - move_count
for move in builder.trade.supplementary_moves[:remaining_slots]:
options.append(
discord.SelectOption(
label=f"{move.player.name}",
description=move.description[:100],
value=str(move.player.id),
)
)
super().__init__(
placeholder="Select a move to remove...",
min_values=1,
max_values=1,
options=options,
)
async def callback(self, interaction: discord.Interaction):
"""Handle move removal selection."""
player_id = int(self.values[0])
success, error_msg = await self.builder.remove_move(player_id)
if success:
await interaction.response.send_message(
f"Removed move for player ID {player_id}", ephemeral=True
)
main_view = TradeEmbedView(self.builder, interaction.user.id)
embed = await create_trade_embed(self.builder)
await interaction.edit_original_response(embed=embed, view=main_view)
else:
await interaction.response.send_message(
f"Could not remove move: {error_msg}", ephemeral=True
)
class SubmitTradeConfirmationModal(discord.ui.Modal):
"""Modal for confirming trade submission - posts acceptance request to trade channel."""
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 for approval",
placeholder="CONFIRM",
required=True,
max_length=7,
)
self.add_item(self.confirmation)
async def on_submit(self, interaction: discord.Interaction):
"""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.",
ephemeral=True,
)
return
await interaction.response.defer(ephemeral=True)
try:
self.builder.trade.status = TradeStatus.PROPOSED
acceptance_embed = await create_trade_acceptance_embed(self.builder)
acceptance_view = TradeAcceptanceView(self.builder)
channel = self.trade_channel
if not channel:
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
if 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:
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(
f"Error submitting trade: {str(e)}", ephemeral=True
)
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."""
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
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)
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
participant = self.builder.trade.get_participant_by_organization(user_team)
if not participant:
return
team_id = participant.team.id
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
all_accepted = self.builder.accept_trade(team_id)
if all_accepted:
await self._finalize_trade(interaction)
else:
embed = await create_trade_acceptance_embed(self.builder)
await interaction.response.edit_message(embed=embed, view=self)
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)
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
self.builder.reject_trade()
self.accept_button.disabled = True
self.reject_button.disabled = True
embed = await create_trade_rejection_embed(self.builder, participant.team)
await interaction.response.edit_message(embed=embed, view=self)
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."""
try:
await interaction.response.defer()
config = get_config()
current = await league_service.get_current_state()
if not current or current.is_past_trade_deadline:
deadline_msg = (
f"❌ **The trade deadline has passed** (Week {current.trade_deadline}). "
f"This trade cannot be finalized."
if current
else "❌ Could not retrieve league state. Please try again later."
)
await interaction.followup.send(deadline_msg, ephemeral=True)
return
next_week = current.week + 1
fa_team = Team(
id=config.free_agent_team_id,
abbrev="FA",
sname="Free Agents",
lname="Free Agency",
season=self.builder.trade.season,
) # type: ignore
transactions: List[Transaction] = []
move_id = f"Trade-{self.builder.trade_id}-{int(datetime.now(timezone.utc).timestamp())}"
for move in self.builder.trade.cross_team_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
)
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,
)
transactions.append(transaction)
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,
)
transactions.append(transaction)
if transactions:
created_transactions = (
await transaction_service.create_transaction_batch(transactions)
)
else:
created_transactions = []
if created_transactions and interaction.client:
await post_trade_to_log(
bot=interaction.client,
builder=self.builder,
transactions=created_transactions,
effective_week=next_week,
)
self.builder.trade.status = TradeStatus.ACCEPTED
self.accept_button.disabled = True
self.reject_button.disabled = True
embed = await create_trade_complete_embed(
self.builder, len(created_transactions), next_week
)
await interaction.edit_original_response(embed=embed, view=self)
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}`"
)
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,
)
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,
)
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,
)
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,
)
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
)
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,
)
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)
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="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.
Args:
builder: TradeBuilder instance
Returns:
Discord embed with current trade state
"""
if builder.is_empty:
color = EmbedColors.SECONDARY
else:
validation = await builder.validate_trade()
color = EmbedColors.SUCCESS if validation.is_legal else EmbedColors.WARNING
embed = EmbedTemplate.create_base_embed(
title=f"Trade Builder - {builder.trade.get_trade_summary()}",
description="Build your multi-team trade",
color=color,
)
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) if team_list else "*No teams yet*",
inline=False,
)
if builder.is_empty:
embed.add_field(
name="Current Moves",
value="*No moves yet. Use the `/trade` commands to build your trade.*",
inline=False,
)
else:
if builder.trade.cross_team_moves:
moves_text = ""
for i, move in enumerate(builder.trade.cross_team_moves[:8], 1):
moves_text += f"{i}. {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 ({len(builder.trade.cross_team_moves)})",
value=moves_text,
inline=False,
)
if builder.trade.supplementary_moves:
supp_text = ""
for i, move in enumerate(builder.trade.supplementary_moves[:5], 1):
supp_text += f"{i}. {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,
)
validation = await builder.validate_trade()
if validation.is_legal:
status_text = "Trade appears legal"
else:
error_count = len(validation.all_errors)
status_text = f"{error_count} error{'s' if error_count != 1 else ''} found\n"
status_text += "\n".join(f"- {error}" for error in validation.all_errors)
if validation.all_suggestions:
status_text += "\n" + "\n".join(
f"- {s}" for s in validation.all_suggestions
)
embed.add_field(name="Quick Status", value=status_text, inline=False)
embed.add_field(
name="Build Your Trade",
value="- `/trade add-player` - Add player exchanges\n- `/trade supplementary` - Add internal moves\n- `/trade add-team` - Add more teams",
inline=False,
)
embed.set_footer(
text=f"Trade ID: {builder.trade_id} | Created: {datetime.fromisoformat(builder.trade.created_at).strftime('%H:%M:%S')}"
)
return embed