major-domo-v2/views/base.py
Cal Corum 82abf3d9e6 CLAUDE: Add toggleable stats to /player command and injury system improvements
Add interactive PlayerStatsView with toggle buttons to show/hide batting and
pitching statistics independently in the /player command. Stats are hidden by
default with clean, user-friendly buttons (💥 batting,  pitching) that update
the embed in-place. Only the command caller can toggle stats, and buttons
timeout after 5 minutes.

Player Stats Toggle Feature:
- Add views/players.py with PlayerStatsView class
- Update /player command to use interactive view
- Stats hidden by default, shown on button click
- Independent batting/pitching toggles
- User-restricted interactions with timeout handling

Injury System Enhancements:
- Add BatterInjuryModal and PitcherRestModal for injury logging
- Add player_id extraction validator to Injury model
- Fix injury creation to merge API request/response data
- Add responders parameter to BaseView for multi-user interactions

API Client Improvements:
- Handle None values correctly in PATCH query parameters
- Convert None to empty string for nullable fields in database

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 23:26:08 -05:00

277 lines
10 KiB
Python

"""
Base View Classes for Discord Bot v2.0
Provides foundational view components with consistent styling and behavior.
"""
import logging
from typing import List, Optional, Any, Callable, Awaitable, Union
from datetime import datetime, timezone
import discord
from discord.ext import commands
from utils.logging import get_contextual_logger
class BaseView(discord.ui.View):
"""Base view class with consistent styling and error handling."""
def __init__(
self,
*,
timeout: float = 180.0,
user_id: Optional[int] = None,
responders: Optional[List[int | None]] = None,
logger_name: Optional[str] = None
):
super().__init__(timeout=timeout)
self.user_id = user_id
self.responders = responders
self.logger = get_contextual_logger(logger_name or f'{__name__}.BaseView')
self.interaction_count = 0
self.created_at = datetime.now(timezone.utc)
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check if user is authorized to interact with this view."""
if self.user_id is None and self.responders is None:
return True
if (self.user_id is not None and interaction.user.id != self.user_id) or (self.responders is not None and interaction.user.id not in self.responders):
await interaction.response.send_message(
"❌ You cannot interact with this menu.",
ephemeral=True
)
return False
return True
async def on_timeout(self) -> None:
"""Handle view timeout."""
self.logger.info("View timed out",
user_id=self.user_id,
interaction_count=self.interaction_count,
timeout=self.timeout)
# Disable all items
for item in self.children:
if hasattr(item, 'disabled'):
item.disabled = True # type: ignore
else:
self.logger.info(f'Item {item} has no "disabled" attribute')
async def on_error(
self,
interaction: discord.Interaction,
error: Exception,
item: discord.ui.Item[Any]
) -> None:
"""Handle view errors."""
self.logger.error("View error occurred",
user_id=interaction.user.id,
error=error,
item_type=type(item).__name__,
interaction_count=self.interaction_count)
try:
if not interaction.response.is_done():
await interaction.response.send_message(
"❌ An error occurred while processing your interaction.",
ephemeral=True
)
else:
await interaction.followup.send(
"❌ An error occurred while processing your interaction.",
ephemeral=True
)
except Exception as e:
self.logger.error("Failed to send error message", error=e)
def increment_interaction_count(self) -> None:
"""Increment the interaction counter."""
self.interaction_count += 1
class ConfirmationView(BaseView):
"""Standard confirmation dialog with Yes/No buttons."""
def __init__(
self,
*,
user_id: Optional[int] = None,
responders: Optional[List[int | None]] = None,
timeout: float = 60.0,
confirm_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None,
cancel_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None,
confirm_label: str = "Confirm",
cancel_label: str = "Cancel"
):
super().__init__(timeout=timeout, user_id=user_id, responders=responders, logger_name=f'{__name__}.ConfirmationView')
self.confirm_callback = confirm_callback
self.cancel_callback = cancel_callback
self.result: Optional[bool] = None
# Update button labels
self.confirm_button.label = confirm_label
self.cancel_button.label = cancel_label
@discord.ui.button(
label="Confirm",
style=discord.ButtonStyle.success,
emoji=""
)
async def confirm_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Handle confirmation."""
self.increment_interaction_count()
self.result = True
# Disable all buttons
for item in self.children:
if hasattr(item, 'disabled'):
item.disabled = True # type: ignore
else:
self.logger.info(f'Item {item} has no "disabled" attribute')
if self.confirm_callback:
await self.confirm_callback(interaction)
else:
await interaction.response.edit_message(
content="✅ Confirmed!",
view=self
)
self.stop()
@discord.ui.button(
label="Cancel",
style=discord.ButtonStyle.secondary,
emoji=""
)
async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Handle cancellation."""
self.increment_interaction_count()
self.result = False
# Disable all buttons
for item in self.children:
if hasattr(item, 'disabled'):
item.disabled = True # type: ignore
else:
self.logger.info(f'Item {item} has no "disabled" attribute')
if self.cancel_callback:
await self.cancel_callback(interaction)
else:
await interaction.response.edit_message(
content="❌ Cancelled.",
view=self
)
self.stop()
class PaginationView(BaseView):
"""Pagination view for navigating through multiple pages."""
def __init__(
self,
*,
pages: list[discord.Embed],
user_id: Optional[int] = None,
timeout: float = 300.0,
show_page_numbers: bool = True,
logger_name: Optional[str] = None
):
super().__init__(timeout=timeout, user_id=user_id, logger_name=logger_name or f'{__name__}.PaginationView')
self.pages = pages
self.current_page = 0
self.show_page_numbers = show_page_numbers
# Update button states
self._update_buttons()
def _update_buttons(self) -> None:
"""Update button enabled/disabled states."""
self.first_page.disabled = self.current_page == 0
self.previous_page.disabled = self.current_page == 0
self.next_page.disabled = self.current_page == len(self.pages) - 1
self.last_page.disabled = self.current_page == len(self.pages) - 1
if self.show_page_numbers:
self.page_info.label = f"{self.current_page + 1}/{len(self.pages)}"
def get_current_embed(self) -> discord.Embed:
"""Get the current page embed with footer."""
embed = self.pages[self.current_page].copy()
if self.show_page_numbers:
footer_text = f"Page {self.current_page + 1} of {len(self.pages)}"
if embed.footer.text:
footer_text = f"{embed.footer.text}{footer_text}"
embed.set_footer(text=footer_text, icon_url=embed.footer.icon_url)
return embed
@discord.ui.button(emoji="", style=discord.ButtonStyle.secondary, row=0)
async def first_page(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Jump to first page."""
self.increment_interaction_count()
self.current_page = 0
self._update_buttons()
await interaction.response.edit_message(embed=self.get_current_embed(), view=self)
@discord.ui.button(emoji="◀️", style=discord.ButtonStyle.primary, row=0)
async def previous_page(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Go to previous page."""
self.increment_interaction_count()
self.current_page = max(0, self.current_page - 1)
self._update_buttons()
await interaction.response.edit_message(embed=self.get_current_embed(), view=self)
@discord.ui.button(label="1/1", style=discord.ButtonStyle.secondary, disabled=True, row=0)
async def page_info(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Page info button (disabled)."""
pass
@discord.ui.button(emoji="▶️", style=discord.ButtonStyle.primary, row=0)
async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Go to next page."""
self.increment_interaction_count()
self.current_page = min(len(self.pages) - 1, self.current_page + 1)
self._update_buttons()
await interaction.response.edit_message(embed=self.get_current_embed(), view=self)
@discord.ui.button(emoji="", style=discord.ButtonStyle.secondary, row=0)
async def last_page(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Jump to last page."""
self.increment_interaction_count()
self.current_page = len(self.pages) - 1
self._update_buttons()
await interaction.response.edit_message(embed=self.get_current_embed(), view=self)
@discord.ui.button(emoji="🗑️", style=discord.ButtonStyle.danger, row=1)
async def delete_message(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Delete the message."""
self.increment_interaction_count()
await interaction.response.defer()
await interaction.delete_original_response()
self.stop()
class SelectMenuView(BaseView):
"""Base class for views with select menus."""
def __init__(
self,
*,
user_id: Optional[int] = None,
timeout: float = 180.0,
placeholder: str = "Select an option...",
min_values: int = 1,
max_values: int = 1,
logger_name: Optional[str] = None
):
super().__init__(timeout=timeout, user_id=user_id, logger_name=logger_name or f'{__name__}.SelectMenuView')
self.placeholder = placeholder
self.min_values = min_values
self.max_values = max_values
self.selected_values: list[str] = []