Merge pull request #18 from calcorum/feature/trade-multi-gm-access
Add trade acceptance workflow with transaction logging (v2.22.0)
This commit is contained in:
commit
7e041ef175
25
CLAUDE.md
25
CLAUDE.md
@ -513,7 +513,30 @@ jq 'select(.extra.duration_ms > 5000)' logs/discord_bot_v2.json
|
||||
- [ ] Added tests following established patterns
|
||||
- [ ] Verified all tests pass
|
||||
|
||||
## 🔄 Recent Major Enhancements (January 2025)
|
||||
## 🔄 Recent Major Enhancements
|
||||
|
||||
### Multi-GM Trade Access (December 2025)
|
||||
**Enables any GM participating in a trade to access and modify the trade builder**
|
||||
|
||||
**Problem Solved**: Previously, only the user who initiated a trade (`/trade initiate`) could add players, view, or modify the trade. Other GMs whose teams were part of the trade had no access.
|
||||
|
||||
**Solution**: Implemented dual-key indexing pattern with a secondary index mapping team IDs to trade keys.
|
||||
|
||||
**Key Changes**:
|
||||
- **`services/trade_builder.py`**:
|
||||
- Added `_team_to_trade_key` secondary index
|
||||
- Added `get_trade_builder_by_team(team_id)` function
|
||||
- Added `clear_trade_builder_by_team(team_id)` function
|
||||
- Updated `add_team()`, `remove_team()`, `clear_trade_builder()` to maintain index
|
||||
- **`commands/transactions/trade.py`**:
|
||||
- Refactored 5 commands to use team-based lookups: `add-team`, `add-player`, `supplementary`, `view`, `clear`
|
||||
- Any GM whose team is in the trade can now use these commands
|
||||
|
||||
**Docker Images**:
|
||||
- `manticorum67/major-domo-discordapp:2.22.0`
|
||||
- `manticorum67/major-domo-discordapp:dev`
|
||||
|
||||
**Tests**: 9 new tests added to `tests/test_services_trade_builder.py`
|
||||
|
||||
### Custom Help Commands System (January 2025)
|
||||
**Comprehensive admin-managed help system for league documentation**:
|
||||
|
||||
@ -152,7 +152,11 @@ This directory contains Discord slash commands for transaction management and ro
|
||||
- Channel receives real-time updates
|
||||
5. **`/trade view`** - Review complete trade with validation
|
||||
- Posts current state to trade channel if viewed elsewhere
|
||||
6. **Submit via interactive UI** - Trade submission through Discord buttons
|
||||
6. **Accept/Reject via interactive UI** - All team GMs must accept for trade to complete
|
||||
- Each GM clicks "Accept Trade" or "Reject Trade" button
|
||||
- Trade status updates show which teams have accepted
|
||||
- Trade executes automatically when all teams accept
|
||||
7. **Trade completion** - Transactions created and posted to #transaction-log
|
||||
|
||||
**Channel Behavior**:
|
||||
- Commands executed **in** the trade channel: Only ephemeral response to user
|
||||
@ -299,6 +303,23 @@ Run tests with:
|
||||
|
||||
## Recent Enhancements
|
||||
|
||||
### December 2025
|
||||
- ✅ **Trade Acceptance Workflow (v2.22.0)**: Multi-team trade approval system
|
||||
- All participating GMs must accept for trade to complete
|
||||
- Accept/Reject buttons with GM permission validation
|
||||
- Real-time status tracking showing which teams have accepted
|
||||
- Automatic trade execution when all teams accept
|
||||
- Transactions created with `frozen=false` for immediate effect
|
||||
- Rich trade embed posted to #transaction-log on completion
|
||||
- **Files**: `views/trade_embed.py`, `services/trade_builder.py`, `utils/transaction_logging.py`
|
||||
|
||||
- ✅ **Trade Transaction Logging**: Rich embeds for completed trades
|
||||
- Groups players by receiving team with "📥 Team receives:" sections
|
||||
- Shows player name, sWAR, and sending team for each move
|
||||
- Uses first team's thumbnail and color for branding
|
||||
- Footer includes Trade ID and SBA season branding
|
||||
- **Function**: `post_trade_to_log()` in `utils/transaction_logging.py`
|
||||
|
||||
### October 2025
|
||||
- ✅ **Real-Time IL Move System (`/ilmove`)**: Immediate transaction execution for current week roster changes
|
||||
- 95% code reuse with `/dropadd` via shared `TransactionBuilder`
|
||||
@ -321,9 +342,7 @@ Run tests with:
|
||||
- ✅ **Interactive Trade UI**: Rich Discord embeds with real-time validation
|
||||
|
||||
## Future Enhancements
|
||||
- **Trade Submission Integration**: Connect trade system to transaction processing pipeline
|
||||
- **Advanced transaction analytics and history
|
||||
- **Trade Approval Workflow**: Multi-party trade approval system
|
||||
- **Roster optimization suggestions
|
||||
- **Automated roster validation alerts
|
||||
- **Trade History Tracking**: Complete audit trail for multi-team trades
|
||||
|
||||
@ -18,7 +18,9 @@ from utils.team_utils import validate_user_has_team, get_team_by_abbrev_with_val
|
||||
from services.trade_builder import (
|
||||
TradeBuilder,
|
||||
get_trade_builder,
|
||||
clear_trade_builder
|
||||
get_trade_builder_by_team,
|
||||
clear_trade_builder,
|
||||
clear_trade_builder_by_team,
|
||||
)
|
||||
from services.player_service import player_service
|
||||
from models.team import RosterType
|
||||
@ -181,20 +183,22 @@ 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)
|
||||
|
||||
# Check if user has an active trade
|
||||
trade_key = f"{interaction.user.id}:trade"
|
||||
from services.trade_builder import _active_trade_builders
|
||||
if trade_key not in _active_trade_builders:
|
||||
# Get user's team first
|
||||
user_team = await validate_user_has_team(interaction)
|
||||
if not user_team:
|
||||
return
|
||||
|
||||
# Look up trade by user's team (allows any GM in the trade to participate)
|
||||
trade_builder = get_trade_builder_by_team(user_team.id)
|
||||
if not trade_builder:
|
||||
await interaction.followup.send(
|
||||
"❌ You don't have an active trade. Use `/trade initiate` first.",
|
||||
"❌ Your team is not part of an active trade. Use `/trade initiate` first.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
trade_builder = _active_trade_builders[trade_key]
|
||||
|
||||
# Get the team to add
|
||||
team_to_add = await get_team_by_abbrev_with_validation(other_team, interaction)
|
||||
if not team_to_add:
|
||||
@ -262,25 +266,22 @@ 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)
|
||||
|
||||
# Check if user has an active trade
|
||||
trade_key = f"{interaction.user.id}:trade"
|
||||
from services.trade_builder import _active_trade_builders
|
||||
if trade_key not in _active_trade_builders:
|
||||
await interaction.followup.send(
|
||||
"❌ You don't have an active trade. Use `/trade initiate` first.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
trade_builder = _active_trade_builders[trade_key]
|
||||
|
||||
# Get user's team
|
||||
# Get user's team first
|
||||
user_team = await validate_user_has_team(interaction)
|
||||
if not user_team:
|
||||
return
|
||||
|
||||
# Look up trade by user's team (allows any GM in the trade to participate)
|
||||
trade_builder = get_trade_builder_by_team(user_team.id)
|
||||
if not trade_builder:
|
||||
await interaction.followup.send(
|
||||
"❌ Your team is not part of an active trade. Use `/trade initiate` or ask another GM to add your team.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Find the player
|
||||
players = await player_service.search_players(player_name, limit=10, season=get_config().sba_season)
|
||||
if not players:
|
||||
@ -372,25 +373,22 @@ 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)
|
||||
|
||||
# Check if user has an active trade
|
||||
trade_key = f"{interaction.user.id}:trade"
|
||||
from services.trade_builder import _active_trade_builders
|
||||
if trade_key not in _active_trade_builders:
|
||||
await interaction.followup.send(
|
||||
"❌ You don't have an active trade. Use `/trade initiate` first.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
trade_builder = _active_trade_builders[trade_key]
|
||||
|
||||
# Get user's team
|
||||
# Get user's team first
|
||||
user_team = await validate_user_has_team(interaction)
|
||||
if not user_team:
|
||||
return
|
||||
|
||||
# Look up trade by user's team (allows any GM in the trade to participate)
|
||||
trade_builder = get_trade_builder_by_team(user_team.id)
|
||||
if not trade_builder:
|
||||
await interaction.followup.send(
|
||||
"❌ Your team is not part of an active trade. Use `/trade initiate` or ask another GM to add your team.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Find the player
|
||||
players = await player_service.search_players(player_name, limit=10, season=get_config().sba_season)
|
||||
if not players:
|
||||
@ -466,19 +464,22 @@ 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)
|
||||
|
||||
trade_key = f"{interaction.user.id}:trade"
|
||||
from services.trade_builder import _active_trade_builders
|
||||
if trade_key not in _active_trade_builders:
|
||||
# Get user's team first
|
||||
user_team = await validate_user_has_team(interaction)
|
||||
if not user_team:
|
||||
return
|
||||
|
||||
# Look up trade by user's team (allows any GM in the trade to view)
|
||||
trade_builder = get_trade_builder_by_team(user_team.id)
|
||||
if not trade_builder:
|
||||
await interaction.followup.send(
|
||||
"❌ You don't have an active trade.",
|
||||
"❌ Your team is not part of an active trade.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
trade_builder = _active_trade_builders[trade_key]
|
||||
|
||||
# Show trade interface
|
||||
embed = await create_trade_embed(trade_builder)
|
||||
view = TradeEmbedView(trade_builder, interaction.user.id)
|
||||
@ -505,27 +506,35 @@ 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 trade_id before clearing (for channel deletion)
|
||||
trade_key = f"{interaction.user.id}:trade"
|
||||
from services.trade_builder import _active_trade_builders
|
||||
trade_id = None
|
||||
if trade_key in _active_trade_builders:
|
||||
trade_id = _active_trade_builders[trade_key].trade_id
|
||||
# Get user's team first
|
||||
user_team = await validate_user_has_team(interaction)
|
||||
if not user_team:
|
||||
return
|
||||
|
||||
# Look up trade by user's team (allows any GM in the trade to clear)
|
||||
trade_builder = get_trade_builder_by_team(user_team.id)
|
||||
if not trade_builder:
|
||||
await interaction.followup.send(
|
||||
"❌ Your team is not part of an active trade.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
trade_id = trade_builder.trade_id
|
||||
|
||||
# Delete associated trade channel if it exists
|
||||
if trade_id:
|
||||
await self.channel_manager.delete_trade_channel(
|
||||
guild=interaction.guild,
|
||||
trade_id=trade_id
|
||||
)
|
||||
await self.channel_manager.delete_trade_channel(
|
||||
guild=interaction.guild,
|
||||
trade_id=trade_id
|
||||
)
|
||||
|
||||
# Clear the trade builder
|
||||
clear_trade_builder(interaction.user.id)
|
||||
# Clear the trade builder using team-based function
|
||||
clear_trade_builder_by_team(user_team.id)
|
||||
|
||||
await interaction.followup.send(
|
||||
"✅ Your trade has been cleared.",
|
||||
"✅ The trade has been cleared.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
11
config.py
11
config.py
@ -39,6 +39,15 @@ class BotConfig(BaseSettings):
|
||||
modern_stats_start_season: int = 8
|
||||
offseason_flag: bool = False # When True, relaxes roster limits and disables weekly freeze/thaw
|
||||
|
||||
# Roster Limits
|
||||
expand_mil_week: int = 15 # Week when MiL roster expands (early vs late limits)
|
||||
ml_roster_limit_early: int = 26 # ML limit for weeks before expand_mil_week
|
||||
ml_roster_limit_late: int = 26 # ML limit for weeks >= expand_mil_week
|
||||
mil_roster_limit_early: int = 6 # MiL limit for weeks before expand_mil_week
|
||||
mil_roster_limit_late: int = 14 # MiL limit for weeks >= expand_mil_week
|
||||
ml_roster_limit_offseason: int = 69 # ML limit during offseason
|
||||
mil_roster_limit_offseason: int = 69 # MiL limit during offseason
|
||||
|
||||
|
||||
# API Constants
|
||||
api_version: str = "v3"
|
||||
@ -54,7 +63,7 @@ class BotConfig(BaseSettings):
|
||||
cap_player_count: int = 26 # Number of players that count toward cap
|
||||
|
||||
# Special Team IDs
|
||||
free_agent_team_id: int = 498
|
||||
free_agent_team_id: int = 547
|
||||
|
||||
# Role Names
|
||||
help_editor_role_name: str = "Help Editor"
|
||||
|
||||
@ -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.
|
||||
@ -137,6 +188,10 @@ class TradeBuilder:
|
||||
# Create transaction builder for this team
|
||||
self._team_builders[team.id] = TransactionBuilder(team, self.trade.initiated_by, self.trade.season)
|
||||
|
||||
# Register team in secondary index for multi-GM access
|
||||
trade_key = f"{self.trade.initiated_by}:trade"
|
||||
_team_to_trade_key[team.id] = trade_key
|
||||
|
||||
logger.info(f"Added team {team.abbrev} to trade {self.trade_id}")
|
||||
return True, ""
|
||||
|
||||
@ -160,10 +215,12 @@ class TradeBuilder:
|
||||
|
||||
# Remove team
|
||||
removed = self.trade.remove_participant(team_id)
|
||||
if removed and team_id in self._team_builders:
|
||||
del self._team_builders[team_id]
|
||||
|
||||
if removed:
|
||||
if team_id in self._team_builders:
|
||||
del self._team_builders[team_id]
|
||||
# Remove from secondary index
|
||||
if team_id in _team_to_trade_key:
|
||||
del _team_to_trade_key[team_id]
|
||||
logger.info(f"Removed team {team_id} from trade {self.trade_id}")
|
||||
|
||||
return removed, "" if removed else "Failed to remove team"
|
||||
@ -444,6 +501,9 @@ class TradeBuilder:
|
||||
# Global cache for active trade builders
|
||||
_active_trade_builders: Dict[str, TradeBuilder] = {}
|
||||
|
||||
# Secondary index: maps team_id -> trade_key for multi-GM access
|
||||
_team_to_trade_key: Dict[int, str] = {}
|
||||
|
||||
|
||||
def get_trade_builder(user_id: int, initiating_team: Team) -> TradeBuilder:
|
||||
"""
|
||||
@ -456,23 +516,80 @@ def get_trade_builder(user_id: int, initiating_team: Team) -> TradeBuilder:
|
||||
Returns:
|
||||
TradeBuilder instance
|
||||
"""
|
||||
# For now, use user_id as the key. In the future, could support multiple concurrent trades
|
||||
trade_key = f"{user_id}:trade"
|
||||
|
||||
if trade_key not in _active_trade_builders:
|
||||
_active_trade_builders[trade_key] = TradeBuilder(user_id, initiating_team)
|
||||
builder = TradeBuilder(user_id, initiating_team)
|
||||
_active_trade_builders[trade_key] = builder
|
||||
# Register initiating team in secondary index for multi-GM access
|
||||
_team_to_trade_key[initiating_team.id] = trade_key
|
||||
|
||||
return _active_trade_builders[trade_key]
|
||||
|
||||
|
||||
def get_trade_builder_by_team(team_id: int) -> Optional[TradeBuilder]:
|
||||
"""
|
||||
Get trade builder that includes a specific team.
|
||||
|
||||
This allows any GM whose team is participating in a trade to access
|
||||
the trade builder, not just the initiator.
|
||||
|
||||
Args:
|
||||
team_id: Team ID to look up
|
||||
|
||||
Returns:
|
||||
TradeBuilder if team is in an active trade, None otherwise
|
||||
"""
|
||||
trade_key = _team_to_trade_key.get(team_id)
|
||||
if trade_key:
|
||||
return _active_trade_builders.get(trade_key)
|
||||
return None
|
||||
|
||||
|
||||
def clear_trade_builder(user_id: int) -> None:
|
||||
"""Clear trade builder for a user."""
|
||||
"""Clear trade builder for a user and remove all team mappings."""
|
||||
trade_key = f"{user_id}:trade"
|
||||
if trade_key in _active_trade_builders:
|
||||
# Remove all team mappings for this trade
|
||||
builder = _active_trade_builders[trade_key]
|
||||
for team in builder.participating_teams:
|
||||
if team.id in _team_to_trade_key:
|
||||
del _team_to_trade_key[team.id]
|
||||
|
||||
del _active_trade_builders[trade_key]
|
||||
logger.info(f"Cleared trade builder for user {user_id}")
|
||||
|
||||
|
||||
def clear_trade_builder_by_team(team_id: int) -> bool:
|
||||
"""
|
||||
Clear trade builder that includes a specific team.
|
||||
|
||||
This allows any GM in a trade to clear it, not just the initiator.
|
||||
|
||||
Args:
|
||||
team_id: Team ID whose trade should be cleared
|
||||
|
||||
Returns:
|
||||
True if a trade was cleared, False if no trade found
|
||||
"""
|
||||
trade_key = _team_to_trade_key.get(team_id)
|
||||
if not trade_key:
|
||||
return False
|
||||
|
||||
if trade_key in _active_trade_builders:
|
||||
builder = _active_trade_builders[trade_key]
|
||||
# Remove all team mappings
|
||||
for team in builder.participating_teams:
|
||||
if team.id in _team_to_trade_key:
|
||||
del _team_to_trade_key[team.id]
|
||||
|
||||
del _active_trade_builders[trade_key]
|
||||
logger.info(f"Cleared trade builder via team {team_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_active_trades() -> Dict[str, TradeBuilder]:
|
||||
"""Get all active trade builders (for debugging/admin purposes)."""
|
||||
return _active_trade_builders.copy()
|
||||
@ -379,7 +379,8 @@ class TransactionBuilder:
|
||||
logger.debug(f"🔍 VALIDATION: Projected roster - ML:{projected_ml_size}, MiL:{projected_mil_size}")
|
||||
logger.debug(f"🔍 VALIDATION: Projected sWAR - ML:{projected_ml_swar:.2f}, MiL:{projected_mil_swar:.2f}")
|
||||
|
||||
# Get current week to determine roster limits
|
||||
# Get current week and config to determine roster limits
|
||||
config = get_config()
|
||||
try:
|
||||
current_state = await league_service.get_current_state()
|
||||
current_week = current_state.week if current_state else 1
|
||||
@ -387,15 +388,18 @@ class TransactionBuilder:
|
||||
logger.warning(f"Could not get current week, using default limits: {e}")
|
||||
current_week = 1
|
||||
|
||||
# Determine roster limits based on week
|
||||
# Major league: <=26 if week<=14, <=25 if week>14
|
||||
# Minor league: <=6 if week<=14, <=14 if week>14
|
||||
if current_week <= 14:
|
||||
ml_limit = 26
|
||||
mil_limit = 6
|
||||
# Determine roster limits based on week and offseason flag
|
||||
# During offseason, limits are relaxed to allow roster building
|
||||
if config.offseason_flag:
|
||||
ml_limit = config.ml_roster_limit_offseason
|
||||
mil_limit = config.mil_roster_limit_offseason
|
||||
logger.debug("🔍 VALIDATION: Offseason mode - using relaxed roster limits")
|
||||
elif current_week < config.expand_mil_week:
|
||||
ml_limit = config.ml_roster_limit_early
|
||||
mil_limit = config.mil_roster_limit_early
|
||||
else:
|
||||
ml_limit = 26
|
||||
mil_limit = 14
|
||||
ml_limit = config.ml_roster_limit_late
|
||||
mil_limit = config.mil_roster_limit_late
|
||||
|
||||
# Validate roster limits
|
||||
is_legal = True
|
||||
|
||||
@ -12,8 +12,11 @@ from services.trade_builder import (
|
||||
TradeBuilder,
|
||||
TradeValidationResult,
|
||||
get_trade_builder,
|
||||
get_trade_builder_by_team,
|
||||
clear_trade_builder,
|
||||
_active_trade_builders
|
||||
clear_trade_builder_by_team,
|
||||
_active_trade_builders,
|
||||
_team_to_trade_key,
|
||||
)
|
||||
from models.trade import TradeStatus
|
||||
from models.team import RosterType, Team
|
||||
@ -496,12 +499,255 @@ 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."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Clear cache before each test."""
|
||||
_active_trade_builders.clear()
|
||||
_team_to_trade_key.clear()
|
||||
|
||||
def test_get_trade_builder(self):
|
||||
"""Test getting trade builder from cache."""
|
||||
@ -534,6 +780,185 @@ class TestTradeBuilderCache:
|
||||
new_builder = get_trade_builder(user_id, team)
|
||||
assert new_builder is not builder
|
||||
|
||||
def test_get_trade_builder_registers_initiating_team(self):
|
||||
"""
|
||||
Test that get_trade_builder registers the initiating team in the secondary index.
|
||||
|
||||
The secondary index allows any GM in the trade to access the builder by team ID,
|
||||
enabling multi-GM participation in trades.
|
||||
"""
|
||||
user_id = 12345
|
||||
team = TeamFactory.west_virginia()
|
||||
|
||||
# Create builder
|
||||
builder = get_trade_builder(user_id, team)
|
||||
|
||||
# Secondary index should have initiating team mapped
|
||||
assert team.id in _team_to_trade_key
|
||||
assert _team_to_trade_key[team.id] == f"{user_id}:trade"
|
||||
|
||||
def test_get_trade_builder_by_team_returns_builder(self):
|
||||
"""
|
||||
Test that get_trade_builder_by_team returns the correct builder for a team.
|
||||
|
||||
This is the core function that enables any GM in a trade to access the builder.
|
||||
"""
|
||||
user_id = 12345
|
||||
team1 = TeamFactory.west_virginia()
|
||||
team2 = TeamFactory.new_york()
|
||||
|
||||
# Create builder and add second team
|
||||
builder = get_trade_builder(user_id, team1)
|
||||
builder.trade.add_participant(team2)
|
||||
# Manually add to secondary index (simulating add_team)
|
||||
_team_to_trade_key[team2.id] = f"{user_id}:trade"
|
||||
|
||||
# Both teams should find the same builder
|
||||
found_by_team1 = get_trade_builder_by_team(team1.id)
|
||||
found_by_team2 = get_trade_builder_by_team(team2.id)
|
||||
|
||||
assert found_by_team1 is builder
|
||||
assert found_by_team2 is builder
|
||||
|
||||
def test_get_trade_builder_by_team_returns_none_for_nonparticipant(self):
|
||||
"""
|
||||
Test that get_trade_builder_by_team returns None for a team not in any trade.
|
||||
|
||||
This ensures proper error handling when a GM tries to access a trade they're not part of.
|
||||
"""
|
||||
user_id = 12345
|
||||
team1 = TeamFactory.west_virginia()
|
||||
team3 = TeamFactory.create(id=999, abbrev="POR", name="Portland") # Non-participant
|
||||
|
||||
# Create builder with team1
|
||||
get_trade_builder(user_id, team1)
|
||||
|
||||
# team3 should not find any builder
|
||||
found = get_trade_builder_by_team(team3.id)
|
||||
assert found is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_team_registers_in_secondary_index(self):
|
||||
"""
|
||||
Test that add_team registers the new team in the secondary index.
|
||||
|
||||
This ensures that when a new team joins a trade, their GM can immediately
|
||||
access the trade builder.
|
||||
"""
|
||||
user_id = 12345
|
||||
team1 = TeamFactory.west_virginia()
|
||||
team2 = TeamFactory.new_york()
|
||||
|
||||
# Create builder
|
||||
builder = get_trade_builder(user_id, team1)
|
||||
|
||||
# Add second team
|
||||
success, error = await builder.add_team(team2)
|
||||
assert success
|
||||
|
||||
# Both teams should be in secondary index
|
||||
assert team1.id in _team_to_trade_key
|
||||
assert team2.id in _team_to_trade_key
|
||||
assert _team_to_trade_key[team1.id] == _team_to_trade_key[team2.id]
|
||||
|
||||
# Both teams should find the same builder
|
||||
assert get_trade_builder_by_team(team1.id) is builder
|
||||
assert get_trade_builder_by_team(team2.id) is builder
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_team_clears_from_secondary_index(self):
|
||||
"""
|
||||
Test that remove_team clears the team from the secondary index.
|
||||
|
||||
This ensures that when a team is removed from a trade, their GM can no
|
||||
longer access the trade builder.
|
||||
"""
|
||||
user_id = 12345
|
||||
team1 = TeamFactory.west_virginia()
|
||||
team2 = TeamFactory.new_york()
|
||||
|
||||
# Create builder and add team2
|
||||
builder = get_trade_builder(user_id, team1)
|
||||
await builder.add_team(team2)
|
||||
|
||||
# Both teams should be in index
|
||||
assert team1.id in _team_to_trade_key
|
||||
assert team2.id in _team_to_trade_key
|
||||
|
||||
# Remove team2
|
||||
success, error = await builder.remove_team(team2.id)
|
||||
assert success
|
||||
|
||||
# team2 should be removed from index, team1 should remain
|
||||
assert team1.id in _team_to_trade_key
|
||||
assert team2.id not in _team_to_trade_key
|
||||
|
||||
def test_clear_trade_builder_clears_secondary_index(self):
|
||||
"""
|
||||
Test that clear_trade_builder removes all teams from secondary index.
|
||||
|
||||
This ensures that when a trade is cleared, all participating GMs lose access.
|
||||
"""
|
||||
user_id = 12345
|
||||
team1 = TeamFactory.west_virginia()
|
||||
team2 = TeamFactory.new_york()
|
||||
|
||||
# Create builder and manually add team2 to secondary index
|
||||
builder = get_trade_builder(user_id, team1)
|
||||
builder.trade.add_participant(team2)
|
||||
_team_to_trade_key[team2.id] = f"{user_id}:trade"
|
||||
|
||||
# Both teams in index
|
||||
assert team1.id in _team_to_trade_key
|
||||
assert team2.id in _team_to_trade_key
|
||||
|
||||
# Clear trade builder
|
||||
clear_trade_builder(user_id)
|
||||
|
||||
# Both teams should be removed from index
|
||||
assert team1.id not in _team_to_trade_key
|
||||
assert team2.id not in _team_to_trade_key
|
||||
|
||||
def test_clear_trade_builder_by_team_clears_all_participants(self):
|
||||
"""
|
||||
Test that clear_trade_builder_by_team removes all teams from secondary index.
|
||||
|
||||
This allows any GM in the trade to clear it, and ensures all participants
|
||||
lose access simultaneously.
|
||||
"""
|
||||
user_id = 12345
|
||||
team1 = TeamFactory.west_virginia()
|
||||
team2 = TeamFactory.new_york()
|
||||
|
||||
# Create builder and manually add team2 to secondary index
|
||||
builder = get_trade_builder(user_id, team1)
|
||||
builder.trade.add_participant(team2)
|
||||
_team_to_trade_key[team2.id] = f"{user_id}:trade"
|
||||
|
||||
# Both teams in index
|
||||
assert team1.id in _team_to_trade_key
|
||||
assert team2.id in _team_to_trade_key
|
||||
|
||||
# Clear using team2's ID (non-initiator)
|
||||
result = clear_trade_builder_by_team(team2.id)
|
||||
assert result is True
|
||||
|
||||
# Both teams should be removed from index
|
||||
assert team1.id not in _team_to_trade_key
|
||||
assert team2.id not in _team_to_trade_key
|
||||
assert len(_active_trade_builders) == 0
|
||||
|
||||
def test_clear_trade_builder_by_team_returns_false_for_nonparticipant(self):
|
||||
"""
|
||||
Test that clear_trade_builder_by_team returns False for non-participating team.
|
||||
|
||||
This ensures proper error handling when a GM not in the trade tries to clear it.
|
||||
"""
|
||||
team3 = TeamFactory.create(id=999, abbrev="POR", name="Portland") # Non-participant
|
||||
|
||||
result = clear_trade_builder_by_team(team3.id)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestTradeValidationResult:
|
||||
"""Test TradeValidationResult functionality."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user