major-domo-v2/views/common.py
Cal Corum e3122fa23a Fix sWAR display precision and draft team role pings
- Change sWAR formatting from 1 decimal to 2 decimal places across all displays
- Draft on-clock announcements now ping team role (via team.lname) instead of GM

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 18:41:46 -06:00

528 lines
18 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Common Discord View Components for Bot v2.0
Specialized views for frequent use cases including player/team selection,
detailed information displays, and interactive menus.
"""
from typing import Optional, List, Dict, Any, Callable, Awaitable, Union
import asyncio
import discord
from discord.ext import commands
from .base import BaseView, PaginationView, SelectMenuView
from .embeds import SBAEmbedTemplate, EmbedTemplate, EmbedColors
from models.player import Player
from models.team import Team
from utils.logging import get_contextual_logger
class PlayerSelectionView(SelectMenuView):
"""Select menu for choosing from multiple players."""
def __init__(
self,
players: List[Player],
*,
user_id: int,
callback: Optional[Callable[[discord.Interaction, Player], Awaitable[None]]] = None,
timeout: float = 60.0,
max_players: int = 25
):
super().__init__(
user_id=user_id,
timeout=timeout,
placeholder="Select a player...",
logger_name=f'{__name__}.PlayerSelectionView'
)
self.players = players[:max_players] # Discord limit
self.callback = callback
self.selected_player: Optional[Player] = None
# Create select menu options
self.add_item(self.player_select)
@discord.ui.select(placeholder="Choose a player...")
async def player_select(self, interaction: discord.Interaction, select: discord.ui.Select):
"""Handle player selection."""
self.increment_interaction_count()
# Find selected player
selected_id = int(select.values[0])
self.selected_player = next(
(p for p in self.players if p.id == selected_id),
None
)
if self.selected_player is None:
await interaction.response.send_message(
"❌ Player not found.",
ephemeral=True
)
return
# Disable the select menu
select.disabled = True
if self.callback:
await self.callback(interaction, self.selected_player)
else:
# Default behavior: show player card
embed = SBAEmbedTemplate.player_card(
player_name=self.selected_player.name,
position=self.selected_player.primary_position,
wara=self.selected_player.wara,
season=self.selected_player.season,
player_image=getattr(self.selected_player, 'image', None)
)
await interaction.response.edit_message(embed=embed, view=self)
self.stop()
def setup_options(self):
"""Setup select menu options from players."""
options = []
for player in self.players:
# Create option label
label = player.name[:100] # Discord limit
description = f"{player.primary_position}"
if hasattr(player, 'team') and player.team:
description += f"{player.team.abbrev}"
# Add WARA if available
if player.wara is not None:
description += f" • WARA: {player.wara:.2f}"
options.append(discord.SelectOption(
label=label,
description=description[:100], # Discord limit
value=str(player.id)
))
self.player_select.options = options
class TeamSelectionView(SelectMenuView):
"""Select menu for choosing from multiple teams."""
def __init__(
self,
teams: List[Team],
*,
user_id: int,
callback: Optional[Callable[[discord.Interaction, Team], Awaitable[None]]] = None,
timeout: float = 60.0,
max_teams: int = 25
):
super().__init__(
user_id=user_id,
timeout=timeout,
placeholder="Select a team...",
logger_name=f'{__name__}.TeamSelectionView'
)
self.teams = teams[:max_teams] # Discord limit
self.callback = callback
self.selected_team: Optional[Team] = None
# Create select menu options
self.add_item(self.team_select)
self.setup_options()
@discord.ui.select(placeholder="Choose a team...")
async def team_select(self, interaction: discord.Interaction, select: discord.ui.Select):
"""Handle team selection."""
self.increment_interaction_count()
# Find selected team
selected_id = int(select.values[0])
self.selected_team = next(
(t for t in self.teams if t.id == selected_id),
None
)
if self.selected_team is None:
await interaction.response.send_message(
"❌ Team not found.",
ephemeral=True
)
return
# Disable the select menu
select.disabled = True
if self.callback:
await self.callback(interaction, self.selected_team)
else:
# Default behavior: show team info
embed = SBAEmbedTemplate.team_info(
team_abbrev=self.selected_team.abbrev,
team_name=self.selected_team.lname,
season=self.selected_team.season,
short_name=getattr(self.selected_team, 'sname', None),
stadium=getattr(self.selected_team, 'stadium', None),
team_color=getattr(self.selected_team, 'color', None),
team_thumbnail=getattr(self.selected_team, 'thumbnail', None)
)
await interaction.response.edit_message(embed=embed, view=self)
self.stop()
def setup_options(self):
"""Setup select menu options from teams."""
options = []
for team in self.teams:
# Create option label
label = f"{team.abbrev} - {team.lname}"[:100] # Discord limit
description = f"Season {team.season}"
if hasattr(team, 'division_id') and team.division_id:
description += f" • Division {team.division_id}"
options.append(discord.SelectOption(
label=label,
description=description[:100], # Discord limit
value=str(team.id)
))
self.team_select.options = options
class DetailedInfoView(BaseView):
"""View for displaying detailed information with action buttons."""
def __init__(
self,
embed: discord.Embed,
*,
user_id: Optional[int] = None,
timeout: float = 300.0,
show_refresh: bool = False,
show_details: bool = False,
refresh_callback: Optional[Callable[[discord.Interaction], Awaitable[discord.Embed]]] = None,
details_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None
):
super().__init__(
timeout=timeout,
user_id=user_id,
logger_name=f'{__name__}.DetailedInfoView'
)
self.embed = embed
self.refresh_callback = refresh_callback
self.details_callback = details_callback
if show_refresh and refresh_callback:
self.add_item(self.refresh_button)
if show_details and details_callback:
self.add_item(self.details_button)
@discord.ui.button(
label="Refresh",
emoji="🔄",
style=discord.ButtonStyle.secondary,
row=0
)
async def refresh_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Refresh the information."""
self.increment_interaction_count()
if self.refresh_callback:
# Show loading state
button.disabled = True
button.label = "Refreshing..."
await interaction.response.edit_message(view=self)
try:
# Get updated embed
new_embed = await self.refresh_callback(interaction)
self.embed = new_embed
# Re-enable button
button.disabled = False
button.label = "Refresh"
await interaction.edit_original_response(embed=new_embed, view=self)
except Exception as e:
self.logger.error("Failed to refresh data", error=e)
button.disabled = False
button.label = "Refresh"
error_embed = EmbedTemplate.error(
title="Refresh Failed",
description="Unable to refresh data. Please try again."
)
await interaction.edit_original_response(embed=error_embed, view=self)
@discord.ui.button(
label="More Details",
emoji="📊",
style=discord.ButtonStyle.primary,
row=0
)
async def details_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Show more details."""
self.increment_interaction_count()
if self.details_callback:
await self.details_callback(interaction)
class SearchResultsView(PaginationView):
"""Paginated view for search results with selection capability."""
def __init__(
self,
results: List[Dict[str, Any]],
search_term: str,
*,
user_id: Optional[int] = None,
timeout: float = 300.0,
results_per_page: int = 10,
selection_callback: Optional[Callable[[discord.Interaction, Dict[str, Any]], Awaitable[None]]] = None
):
self.results = results
self.search_term = search_term
self.results_per_page = results_per_page
self.selection_callback = selection_callback
# Create pages
pages = self._create_pages()
super().__init__(
pages=pages,
user_id=user_id,
timeout=timeout,
logger_name=f'{__name__}.SearchResultsView'
)
# Add selection dropdown if callback provided
if selection_callback and results:
self.add_item(self.result_select)
self.setup_selection_options()
def _create_pages(self) -> List[discord.Embed]:
"""Create embed pages from search results."""
pages = []
for i in range(0, len(self.results), self.results_per_page):
page_results = self.results[i:i + self.results_per_page]
embed = SBAEmbedTemplate.search_results(
search_term=self.search_term,
results=page_results,
max_results=self.results_per_page
)
pages.append(embed)
if not pages:
# No results page
embed = SBAEmbedTemplate.search_results(
search_term=self.search_term,
results=[],
max_results=self.results_per_page
)
pages.append(embed)
return pages
@discord.ui.select(placeholder="Select a result...", row=1)
async def result_select(self, interaction: discord.Interaction, select: discord.ui.Select):
"""Handle result selection."""
self.increment_interaction_count()
if self.selection_callback:
# Find selected result
selected_index = int(select.values[0])
if 0 <= selected_index < len(self.results):
selected_result = self.results[selected_index]
await self.selection_callback(interaction, selected_result)
else:
await interaction.response.send_message(
"❌ Invalid selection.",
ephemeral=True
)
def setup_selection_options(self):
"""Setup selection dropdown options."""
options = []
# Show results for current page
start_idx = self.current_page * self.results_per_page
end_idx = min(start_idx + self.results_per_page, len(self.results))
for i in range(start_idx, end_idx):
result = self.results[i]
label = result.get('name', f'Result {i + 1}')[:100]
description = result.get('detail', '')[:100]
options.append(discord.SelectOption(
label=label,
description=description,
value=str(i)
))
if options:
self.result_select.options = options
self.result_select.disabled = False
else:
self.result_select.disabled = True
class QuickActionView(BaseView):
"""View with quick action buttons for common operations."""
def __init__(
self,
*,
user_id: Optional[int] = None,
timeout: float = 180.0,
actions: Optional[List[Dict[str, Any]]] = None
):
super().__init__(
timeout=timeout,
user_id=user_id,
logger_name=f'{__name__}.QuickActionView'
)
self.actions = actions or []
self._setup_action_buttons()
def _setup_action_buttons(self):
"""Setup action buttons from actions list."""
for i, action in enumerate(self.actions[:25]): # Discord limit
button = discord.ui.Button(
label=action.get('label', f'Action {i + 1}'),
emoji=action.get('emoji'),
style=getattr(discord.ButtonStyle, action.get('style', 'secondary')),
custom_id=f'action_{i}',
row=i // 5 # 5 buttons per row
)
async def button_callback(interaction: discord.Interaction, btn=button, act=action):
self.increment_interaction_count()
callback = act.get('callback')
if callback:
await callback(interaction)
button.callback = button_callback
self.add_item(button)
class SettingsView(BaseView):
"""View for displaying and modifying settings."""
def __init__(
self,
settings: Dict[str, Any],
*,
user_id: int,
timeout: float = 300.0,
save_callback: Optional[Callable[[Dict[str, Any]], Awaitable[bool]]] = None
):
super().__init__(
timeout=timeout,
user_id=user_id,
logger_name=f'{__name__}.SettingsView'
)
self.settings = settings.copy()
self.original_settings = settings.copy()
self.save_callback = save_callback
self.has_changes = False
def create_settings_embed(self) -> discord.Embed:
"""Create embed showing current settings."""
embed = EmbedTemplate.create_base_embed(
title="⚙️ Settings",
color=EmbedColors.SECONDARY
)
for key, value in self.settings.items():
embed.add_field(
name=key.replace('_', ' ').title(),
value=str(value),
inline=True
)
if self.has_changes:
embed.set_footer(text="⚠️ You have unsaved changes")
return embed
@discord.ui.button(
label="Save Changes",
emoji="💾",
style=discord.ButtonStyle.success,
row=0
)
async def save_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Save settings changes."""
self.increment_interaction_count()
if not self.has_changes:
await interaction.response.send_message(
" No changes to save.",
ephemeral=True
)
return
if self.save_callback:
button.disabled = True
await interaction.response.edit_message(view=self)
try:
success = await self.save_callback(self.settings)
if success:
self.has_changes = False
self.original_settings = self.settings.copy()
embed = EmbedTemplate.success(
title="Settings Saved",
description="Your settings have been saved successfully."
)
else:
embed = EmbedTemplate.error(
title="Save Failed",
description="Failed to save settings. Please try again."
)
button.disabled = False
await interaction.edit_original_response(embed=embed, view=self)
except Exception as e:
self.logger.error("Failed to save settings", error=e)
button.disabled = False
embed = EmbedTemplate.error(
title="Save Error",
description="An error occurred while saving settings."
)
await interaction.edit_original_response(embed=embed, view=self)
@discord.ui.button(
label="Reset",
emoji="🔄",
style=discord.ButtonStyle.danger,
row=0
)
async def reset_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Reset settings to original values."""
self.increment_interaction_count()
self.settings = self.original_settings.copy()
self.has_changes = False
embed = self.create_settings_embed()
await interaction.response.edit_message(embed=embed, view=self)