CLAUDE: Add pagination and move ID display to /mymoves command

- Fix transaction count bug by filtering transactions >= current week
- Add TransactionPaginationView with "Show Move IDs" button
- Implement intelligent message chunking for long transaction lists
- Remove emojis from individual transaction lines (kept in headers)
- Display 10 transactions per page with navigation buttons
- Show move IDs on demand via ephemeral button (stays under 2000 char limit)
- Update all tests to validate pagination and chunking behavior

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-14 16:51:24 -05:00
parent b61cad2478
commit 3c66ada99d
3 changed files with 453 additions and 155 deletions

View File

@ -12,7 +12,9 @@ from discord import app_commands
from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from utils.team_utils import get_user_major_league_team
from views.embeds import EmbedColors, EmbedTemplate
from views.base import PaginationView
from constants import SBA_CURRENT_SEASON
from services.transaction_service import transaction_service
@ -21,6 +23,81 @@ from services.team_service import team_service
# No longer need TransactionStatus enum
class TransactionPaginationView(PaginationView):
"""Custom pagination view with Show Move IDs button."""
def __init__(
self,
*,
pages: list[discord.Embed],
all_transactions: list,
user_id: int,
timeout: float = 300.0,
show_page_numbers: bool = True
):
super().__init__(
pages=pages,
user_id=user_id,
timeout=timeout,
show_page_numbers=show_page_numbers
)
self.all_transactions = all_transactions
@discord.ui.button(label="Show Move IDs", style=discord.ButtonStyle.secondary, emoji="🔍", row=1)
async def show_move_ids(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Show all move IDs in an ephemeral message."""
self.increment_interaction_count()
if not self.all_transactions:
await interaction.response.send_message(
"No transactions to show.",
ephemeral=True
)
return
# Build the move ID list
header = "📋 **Move IDs for Your Transactions**\n"
lines = []
for transaction in self.all_transactions:
lines.append(
f"• Week {transaction.week}: {transaction.player.name} → `{transaction.moveid}`"
)
# Discord has a 2000 character limit for messages
# Chunk messages to stay under the limit
messages = []
current_message = header
for line in lines:
# Check if adding this line would exceed limit (leave 50 char buffer)
if len(current_message) + len(line) + 1 > 1950:
messages.append(current_message)
current_message = line + "\n"
else:
current_message += line + "\n"
# Add the last message if it has content beyond the header
if current_message.strip() != header.strip():
messages.append(current_message)
# Send the messages
if not messages:
await interaction.response.send_message(
"No transactions to display.",
ephemeral=True
)
return
# Send first message as response
await interaction.response.send_message(messages[0], ephemeral=True)
# Send remaining messages as followups
if len(messages) > 1:
for msg in messages[1:]:
await interaction.followup.send(msg, ephemeral=True)
class TransactionCommands(commands.Cog):
"""Transaction command handlers for roster management."""
@ -45,17 +122,15 @@ class TransactionCommands(commands.Cog):
await interaction.response.defer()
# Get user's team
user_teams = await team_service.get_teams_by_owner(interaction.user.id, SBA_CURRENT_SEASON)
team = await get_user_major_league_team(interaction.user.id, SBA_CURRENT_SEASON)
if not user_teams:
if not team:
await interaction.followup.send(
"❌ You don't appear to own a team in the current season.",
ephemeral=True
)
return
team = user_teams[0] # Use first team if multiple
# Get transactions in parallel
pending_task = transaction_service.get_pending_transactions(team.abbrev, SBA_CURRENT_SEASON)
frozen_task = transaction_service.get_frozen_transactions(team.abbrev, SBA_CURRENT_SEASON)
@ -74,7 +149,7 @@ class TransactionCommands(commands.Cog):
cancelled=True
)
embed = await self._create_my_moves_embed(
pages = self._create_my_moves_pages(
team,
pending_transactions,
frozen_transactions,
@ -82,7 +157,27 @@ class TransactionCommands(commands.Cog):
cancelled_transactions
)
await interaction.followup.send(embed=embed)
# Collect all transactions for the "Show Move IDs" button
all_transactions = (
pending_transactions +
frozen_transactions +
processed_transactions +
cancelled_transactions
)
# If only one page and no transactions, send without any buttons
if len(pages) == 1 and not all_transactions:
await interaction.followup.send(embed=pages[0])
else:
# Use custom pagination view with "Show Move IDs" button
view = TransactionPaginationView(
pages=pages,
all_transactions=all_transactions,
user_id=interaction.user.id,
timeout=300.0,
show_page_numbers=True
)
await interaction.followup.send(embed=view.get_current_embed(), view=view)
@app_commands.command(
name="legal",
@ -159,106 +254,166 @@ class TransactionCommands(commands.Cog):
await interaction.followup.send(embed=embed)
async def _create_my_moves_embed(
def _create_my_moves_pages(
self,
team,
pending_transactions,
frozen_transactions,
processed_transactions,
cancelled_transactions
) -> discord.Embed:
"""Create embed showing user's transaction status."""
) -> list[discord.Embed]:
"""Create paginated embeds showing user's transaction status."""
embed = EmbedTemplate.create_base_embed(
title=f"📋 Transaction Status - {team.abbrev}",
description=f"{team.lname} • Season {SBA_CURRENT_SEASON}",
color=EmbedColors.INFO
)
pages = []
transactions_per_page = 10
# Add team thumbnail if available
if hasattr(team, 'thumbnail') and team.thumbnail:
embed.set_thumbnail(url=team.thumbnail)
# Helper function to create transaction lines without emojis
def format_transaction(transaction):
return f"Week {transaction.week}: {transaction.move_description}"
# Pending transactions
# Page 1: Summary + Pending Transactions
if pending_transactions:
pending_lines = []
for transaction in pending_transactions[-5:]: # Show last 5
pending_lines.append(
f"{transaction.status_emoji} Week {transaction.week}: {transaction.move_description}"
total_pending = len(pending_transactions)
total_pages = (total_pending + transactions_per_page - 1) // transactions_per_page
for page_num in range(total_pages):
start_idx = page_num * transactions_per_page
end_idx = min(start_idx + transactions_per_page, total_pending)
page_transactions = pending_transactions[start_idx:end_idx]
embed = EmbedTemplate.create_base_embed(
title=f"📋 Transaction Status - {team.abbrev}",
description=f"{team.lname} • Season {SBA_CURRENT_SEASON}",
color=EmbedColors.INFO
)
embed.add_field(
name="⏳ Pending Transactions",
value="\n".join(pending_lines),
inline=False
)
# Add team thumbnail if available
if hasattr(team, 'thumbnail') and team.thumbnail:
embed.set_thumbnail(url=team.thumbnail)
# Pending transactions for this page
pending_lines = [format_transaction(tx) for tx in page_transactions]
embed.add_field(
name=f"⏳ Pending Transactions ({total_pending} total)",
value="\n".join(pending_lines),
inline=False
)
# Add summary only on first page
if page_num == 0:
total_frozen = len(frozen_transactions)
status_text = []
if total_pending > 0:
status_text.append(f"{total_pending} pending")
if total_frozen > 0:
status_text.append(f"{total_frozen} scheduled")
embed.add_field(
name="Summary",
value=", ".join(status_text) if status_text else "No active transactions",
inline=True
)
pages.append(embed)
else:
# No pending transactions - create single page
embed = EmbedTemplate.create_base_embed(
title=f"📋 Transaction Status - {team.abbrev}",
description=f"{team.lname} • Season {SBA_CURRENT_SEASON}",
color=EmbedColors.INFO
)
if hasattr(team, 'thumbnail') and team.thumbnail:
embed.set_thumbnail(url=team.thumbnail)
embed.add_field(
name="⏳ Pending Transactions",
value="No pending transactions",
inline=False
)
# Frozen transactions (scheduled for processing)
if frozen_transactions:
frozen_lines = []
for transaction in frozen_transactions[-3:]: # Show last 3
frozen_lines.append(
f"{transaction.status_emoji} Week {transaction.week}: {transaction.move_description}"
)
total_frozen = len(frozen_transactions)
status_text = []
if total_frozen > 0:
status_text.append(f"{total_frozen} scheduled")
embed.add_field(
name="❄️ Scheduled for Processing",
name="Summary",
value=", ".join(status_text) if status_text else "No active transactions",
inline=True
)
pages.append(embed)
# Additional page: Frozen transactions
if frozen_transactions:
embed = EmbedTemplate.create_base_embed(
title=f"📋 Transaction Status - {team.abbrev}",
description=f"{team.lname} • Season {SBA_CURRENT_SEASON}",
color=EmbedColors.INFO
)
if hasattr(team, 'thumbnail') and team.thumbnail:
embed.set_thumbnail(url=team.thumbnail)
frozen_lines = [format_transaction(tx) for tx in frozen_transactions]
embed.add_field(
name=f"❄️ Scheduled for Processing ({len(frozen_transactions)} total)",
value="\n".join(frozen_lines),
inline=False
)
# Recent processed transactions
pages.append(embed)
# Additional page: Recently processed transactions
if processed_transactions:
processed_lines = []
for transaction in processed_transactions[-3:]: # Show last 3
processed_lines.append(
f"{transaction.status_emoji} Week {transaction.week}: {transaction.move_description}"
)
embed = EmbedTemplate.create_base_embed(
title=f"📋 Transaction Status - {team.abbrev}",
description=f"{team.lname} • Season {SBA_CURRENT_SEASON}",
color=EmbedColors.INFO
)
if hasattr(team, 'thumbnail') and team.thumbnail:
embed.set_thumbnail(url=team.thumbnail)
processed_lines = [format_transaction(tx) for tx in processed_transactions[-20:]] # Last 20
embed.add_field(
name="✅ Recently Processed",
name=f"✅ Recently Processed ({len(processed_transactions[-20:])} shown)",
value="\n".join(processed_lines),
inline=False
)
# Cancelled transactions (if requested)
pages.append(embed)
# Additional page: Cancelled transactions (if requested)
if cancelled_transactions:
cancelled_lines = []
for transaction in cancelled_transactions[-2:]: # Show last 2
cancelled_lines.append(
f"{transaction.status_emoji} Week {transaction.week}: {transaction.move_description}"
)
embed = EmbedTemplate.create_base_embed(
title=f"📋 Transaction Status - {team.abbrev}",
description=f"{team.lname} • Season {SBA_CURRENT_SEASON}",
color=EmbedColors.INFO
)
if hasattr(team, 'thumbnail') and team.thumbnail:
embed.set_thumbnail(url=team.thumbnail)
cancelled_lines = [format_transaction(tx) for tx in cancelled_transactions[-20:]] # Last 20
embed.add_field(
name="❌ Cancelled Transactions",
name=f"❌ Cancelled Transactions ({len(cancelled_transactions[-20:])} shown)",
value="\n".join(cancelled_lines),
inline=False
)
# Transaction summary
total_pending = len(pending_transactions)
total_frozen = len(frozen_transactions)
pages.append(embed)
status_text = []
if total_pending > 0:
status_text.append(f"{total_pending} pending")
if total_frozen > 0:
status_text.append(f"{total_frozen} scheduled")
# Add footer to all pages
for page in pages:
page.set_footer(text="Use /legal to check roster legality")
embed.add_field(
name="Summary",
value=", ".join(status_text) if status_text else "No active transactions",
inline=True
)
embed.set_footer(text="Use /legal to check roster legality")
return embed
return pages
async def _create_legal_embed(
self,

View File

@ -77,13 +77,30 @@ class TransactionService(BaseService[Transaction]):
raise APIException(f"Failed to retrieve transactions: {e}")
async def get_pending_transactions(self, team_abbrev: str, season: int) -> List[Transaction]:
"""Get pending transactions for a team."""
return await self.get_team_transactions(
team_abbrev,
season,
cancelled=False,
frozen=False
)
"""Get pending (future) transactions for a team."""
try:
# Get current week to filter future transactions
current_data = await self.get_client()
current_response = await current_data.get('current')
current_week = current_response.get('week', 0) if current_response else 0
# Get transactions from current week onward
return await self.get_team_transactions(
team_abbrev,
season,
cancelled=False,
frozen=False,
week_start=current_week
)
except Exception as e:
logger.warning(f"Could not get current week, returning all non-cancelled/non-frozen transactions: {e}")
# Fallback to all non-cancelled/non-frozen if we can't get current week
return await self.get_team_transactions(
team_abbrev,
season,
cancelled=False,
frozen=False
)
async def get_frozen_transactions(self, team_abbrev: str, season: int) -> List[Transaction]:
"""Get frozen (scheduled for processing) transactions for a team."""

View File

@ -114,11 +114,11 @@ class TestTransactionCommands:
frozen_tx = [tx for tx in mock_transactions if tx.is_frozen]
cancelled_tx = [tx for tx in mock_transactions if tx.is_cancelled]
with patch('commands.transactions.management.team_service') as mock_team_service:
with patch('utils.team_utils.team_service') as mock_team_utils_service:
with patch('commands.transactions.management.transaction_service') as mock_tx_service:
# Mock service responses
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
mock_team_utils_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
mock_tx_service.get_pending_transactions = AsyncMock(return_value=pending_tx)
mock_tx_service.get_frozen_transactions = AsyncMock(return_value=frozen_tx)
mock_tx_service.get_processed_transactions = AsyncMock(return_value=[])
@ -131,9 +131,6 @@ class TestTransactionCommands:
mock_interaction.followup.send.assert_called_once()
# Verify service calls
mock_team_service.get_teams_by_owner.assert_called_once_with(
mock_interaction.user.id, 12
)
mock_tx_service.get_pending_transactions.assert_called_once_with('WV', 12)
mock_tx_service.get_frozen_transactions.assert_called_once_with('WV', 12)
mock_tx_service.get_processed_transactions.assert_called_once_with('WV', 12)
@ -147,7 +144,7 @@ class TestTransactionCommands:
"""Test /mymoves command with cancelled transactions shown."""
cancelled_tx = [tx for tx in mock_transactions if tx.is_cancelled]
with patch('commands.transactions.management.team_service') as mock_team_service:
with patch('utils.team_utils.team_service') as mock_team_service:
with patch('commands.transactions.management.transaction_service') as mock_tx_service:
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
@ -166,7 +163,7 @@ class TestTransactionCommands:
@pytest.mark.asyncio
async def test_my_moves_no_team(self, commands_cog, mock_interaction):
"""Test /mymoves command when user has no team."""
with patch('commands.transactions.management.team_service') as mock_team_service:
with patch('utils.team_utils.team_service') as mock_team_service:
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[])
await commands_cog.my_moves.callback(commands_cog, mock_interaction)
@ -180,7 +177,7 @@ class TestTransactionCommands:
@pytest.mark.asyncio
async def test_my_moves_api_error(self, commands_cog, mock_interaction, mock_team):
"""Test /mymoves command with API error."""
with patch('commands.transactions.management.team_service') as mock_team_service:
with patch('utils.team_utils.team_service') as mock_team_service:
with patch('commands.transactions.management.transaction_service') as mock_tx_service:
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
@ -308,38 +305,49 @@ class TestTransactionCommands:
assert "Could not retrieve roster data" in call_args.args[0]
@pytest.mark.asyncio
async def test_create_my_moves_embed(self, commands_cog, mock_team, mock_transactions):
"""Test embed creation for /mymoves command."""
async def test_create_my_moves_pages(self, commands_cog, mock_team, mock_transactions):
"""Test paginated embed creation for /mymoves command."""
pending_tx = [tx for tx in mock_transactions if tx.is_pending]
frozen_tx = [tx for tx in mock_transactions if tx.is_frozen]
processed_tx = []
cancelled_tx = [tx for tx in mock_transactions if tx.is_cancelled]
embed = await commands_cog._create_my_moves_embed(
pages = commands_cog._create_my_moves_pages(
mock_team, pending_tx, frozen_tx, processed_tx, cancelled_tx
)
assert isinstance(embed, discord.Embed)
assert embed.title == "📋 Transaction Status - WV"
assert "West Virginia Black Bears • Season 12" in embed.description
assert len(pages) > 0
first_page = pages[0]
assert isinstance(first_page, discord.Embed)
assert first_page.title == "📋 Transaction Status - WV"
assert "West Virginia Black Bears • Season 12" in first_page.description
# Check that fields are created for each transaction type
field_names = [field.name for field in embed.fields]
assert "⏳ Pending Transactions" in field_names
assert "❄️ Scheduled for Processing" in field_names
assert "❌ Cancelled Transactions" in field_names
assert "Summary" in field_names
# Check that fields are created for transaction types
field_names = [field.name for field in first_page.fields]
assert any("Pending Transactions" in name for name in field_names)
# Verify thumbnail is set
assert embed.thumbnail.url == mock_team.thumbnail
assert first_page.thumbnail.url == mock_team.thumbnail
# Verify emoji is NOT in individual transaction lines
for page in pages:
for field in page.fields:
if "Pending" in field.name or "Scheduled" in field.name or "Cancelled" in field.name:
# Check that emojis (⏳, ❄️, ❌) are NOT in the field value
assert "" not in field.value
assert "❄️" not in field.value
assert "" not in field.value
@pytest.mark.asyncio
async def test_create_my_moves_embed_no_transactions(self, commands_cog, mock_team):
"""Test embed creation with no transactions."""
embed = await commands_cog._create_my_moves_embed(
async def test_create_my_moves_pages_no_transactions(self, commands_cog, mock_team):
"""Test paginated embed creation with no transactions."""
pages = commands_cog._create_my_moves_pages(
mock_team, [], [], [], []
)
assert len(pages) == 1 # Should have single page
embed = pages[0]
# Find the pending transactions field
pending_field = next(f for f in embed.fields if "Pending" in f.name)
assert pending_field.value == "No pending transactions"
@ -348,6 +356,117 @@ class TestTransactionCommands:
summary_field = next(f for f in embed.fields if f.name == "Summary")
assert summary_field.value == "No active transactions"
@pytest.mark.asyncio
async def test_transaction_pagination_view_with_move_ids(self, commands_cog, mock_interaction, mock_team, mock_transactions):
"""Test that TransactionPaginationView is created with move IDs button."""
from commands.transactions.management import TransactionPaginationView
pending_tx = [tx for tx in mock_transactions if tx.is_pending]
with patch('utils.team_utils.team_service') as mock_team_service:
with patch('commands.transactions.management.transaction_service') as mock_tx_service:
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
mock_tx_service.get_pending_transactions = AsyncMock(return_value=pending_tx)
mock_tx_service.get_frozen_transactions = AsyncMock(return_value=[])
mock_tx_service.get_processed_transactions = AsyncMock(return_value=[])
await commands_cog.my_moves.callback(commands_cog, mock_interaction, show_cancelled=False)
# Verify TransactionPaginationView was created
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
view = call_args.kwargs.get('view')
assert view is not None
assert isinstance(view, TransactionPaginationView)
assert len(view.all_transactions) == len(pending_tx)
@pytest.mark.asyncio
async def test_show_move_ids_handles_long_lists(self, mock_team, mock_transactions):
"""Test that Show Move IDs button properly chunks very long transaction lists."""
from commands.transactions.management import TransactionPaginationView
# Create 100 transactions to simulate a very long list
base_data = {
'season': 12,
'player': {
'id': 12472,
'name': 'Very Long Player Name That Takes Up Space',
'wara': 2.47,
'season': 12,
'pos_1': 'LF'
},
'oldteam': {
'id': 508,
'abbrev': 'NYD',
'sname': 'Diamonds',
'lname': 'New York Diamonds',
'season': 12
},
'newteam': {
'id': 499,
'abbrev': 'WV',
'sname': 'Black Bears',
'lname': 'West Virginia Black Bears',
'season': 12
}
}
many_transactions = []
for i in range(100):
tx_data = {
**base_data,
'id': i,
'week': 10 + (i % 5),
'moveid': f'Season-012-Week-{10 + (i % 5)}-Move-{i:03d}',
'cancelled': False,
'frozen': False
}
many_transactions.append(Transaction.from_api_data(tx_data))
# Create view with many transactions
pages = [discord.Embed(title="Test")]
view = TransactionPaginationView(
pages=pages,
all_transactions=many_transactions,
user_id=258104532423147520,
timeout=300.0,
show_page_numbers=True
)
# Create mock interaction
mock_interaction = AsyncMock()
mock_button = MagicMock()
# Find the show_move_ids button and call its callback directly
show_move_ids_button = None
for item in view.children:
if hasattr(item, 'label') and item.label == "Show Move IDs":
show_move_ids_button = item
break
assert show_move_ids_button is not None, "Show Move IDs button not found"
# Call the button's callback
await show_move_ids_button.callback(mock_interaction)
# Verify response was sent
mock_interaction.response.send_message.assert_called_once()
# Get the message that was sent
call_args = mock_interaction.response.send_message.call_args
first_message = call_args.args[0]
# Verify first message is under 2000 characters
assert len(first_message) < 2000, f"First message is {len(first_message)} characters (exceeds 2000 limit)"
# If there were followup messages, verify they're also under 2000 chars
if mock_interaction.followup.send.called:
for call in mock_interaction.followup.send.call_args_list:
followup_message = call.args[0]
assert len(followup_message) < 2000, f"Followup message is {len(followup_message)} characters (exceeds 2000 limit)"
@pytest.mark.asyncio
async def test_create_legal_embed_all_legal(self, commands_cog, mock_team):
"""Test legal embed creation when all rosters are legal."""
@ -495,7 +614,7 @@ class TestTransactionCommandsIntegration:
'season': 12
})
with patch('commands.transactions.management.team_service') as mock_team_service:
with patch('utils.team_utils.team_service') as mock_team_service:
with patch('commands.transactions.management.transaction_service') as mock_tx_service:
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
@ -510,15 +629,22 @@ class TestTransactionCommandsIntegration:
embed_call = mock_interaction.followup.send.call_args
embed = embed_call.kwargs['embed']
# Check that only last 5 pending transactions are shown
# With 15 transactions, should show 10 per page
pending_field = next(f for f in embed.fields if "Pending" in f.name)
lines = pending_field.value.split('\n')
assert len(lines) == 5 # Should show only last 5
assert len(lines) == 10 # Should show 10 per page
# Verify summary shows correct count
summary_field = next(f for f in embed.fields if f.name == "Summary")
assert "15 pending" in summary_field.value
# Verify pagination view was created
from commands.transactions.management import TransactionPaginationView
view = embed_call.kwargs.get('view')
assert view is not None
assert isinstance(view, TransactionPaginationView)
assert len(view.all_transactions) == 15
@pytest.mark.asyncio
async def test_concurrent_command_execution(self, commands_cog):
"""Test that commands can handle concurrent execution."""
@ -539,7 +665,7 @@ class TestTransactionCommandsIntegration:
'season': 12
})
with patch('commands.transactions.management.team_service') as mock_team_service:
with patch('utils.team_utils.team_service') as mock_team_service:
with patch('commands.transactions.management.transaction_service') as mock_tx_service:
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])