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>
This commit is contained in:
parent
9fca0bc279
commit
82abf3d9e6
@ -339,7 +339,10 @@ class APIClient:
|
||||
|
||||
# Add data as query parameters if requested
|
||||
if use_query_params and data:
|
||||
params = [(k, str(v)) for k, v in data.items()]
|
||||
# Handle None values by converting to empty string
|
||||
# The database API's PATCH endpoint treats empty strings as NULL for nullable fields
|
||||
# Example: {'il_return': None} → ?il_return= → Database sets il_return to NULL
|
||||
params = [(k, '' if v is None else str(v)) for k, v in data.items()]
|
||||
url = self._add_params(url, params)
|
||||
|
||||
await self._ensure_session()
|
||||
|
||||
@ -14,8 +14,7 @@ from services.player_service import player_service
|
||||
from services.stats_service import stats_service
|
||||
from utils.logging import get_contextual_logger
|
||||
from utils.decorators import logged_command
|
||||
from views.embeds import EmbedColors, EmbedTemplate
|
||||
from models.team import RosterType
|
||||
from views.players import PlayerStatsView
|
||||
|
||||
|
||||
async def player_name_autocomplete(
|
||||
@ -155,176 +154,21 @@ class PlayerInfoCommands(commands.Cog):
|
||||
batting_stats=bool(batting_stats),
|
||||
pitching_stats=bool(pitching_stats))
|
||||
|
||||
# Create comprehensive player embed with statistics
|
||||
self.logger.debug("Creating Discord embed with statistics")
|
||||
embed = await self._create_player_embed_with_stats(
|
||||
player_with_team,
|
||||
search_season,
|
||||
batting_stats,
|
||||
pitching_stats
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
async def _create_player_embed_with_stats(
|
||||
self,
|
||||
player,
|
||||
season: int,
|
||||
batting_stats=None,
|
||||
pitching_stats=None
|
||||
) -> discord.Embed:
|
||||
"""Create a comprehensive player embed with statistics."""
|
||||
# Determine embed color based on team
|
||||
embed_color = EmbedColors.PRIMARY
|
||||
if hasattr(player, 'team') and player.team and hasattr(player.team, 'color'):
|
||||
try:
|
||||
# Convert hex color string to int
|
||||
embed_color = int(player.team.color, 16)
|
||||
except (ValueError, TypeError):
|
||||
embed_color = EmbedColors.PRIMARY
|
||||
|
||||
# Create base embed
|
||||
embed = EmbedTemplate.create_base_embed(
|
||||
title=f"🏟️ {player.name}",
|
||||
color=embed_color
|
||||
)
|
||||
|
||||
# Set team logo beside player name (as author icon)
|
||||
if hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail:
|
||||
embed.set_author(
|
||||
name=player.name,
|
||||
icon_url=player.team.thumbnail
|
||||
)
|
||||
# Remove the emoji from title since we're using author
|
||||
embed.title = None
|
||||
|
||||
# Basic info section
|
||||
embed.add_field(
|
||||
name="Position",
|
||||
value=player.primary_position,
|
||||
inline=True
|
||||
)
|
||||
|
||||
if hasattr(player, 'team') and player.team:
|
||||
embed.add_field(
|
||||
name="Team",
|
||||
value=f"{player.team.abbrev} - {player.team.sname}",
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Add Major League affiliate if this is a Minor League team
|
||||
if player.team.roster_type() == RosterType.MINOR_LEAGUE:
|
||||
major_affiliate = player.team.get_major_league_affiliate()
|
||||
if major_affiliate:
|
||||
embed.add_field(
|
||||
name="Major Affiliate",
|
||||
value=major_affiliate,
|
||||
inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="sWAR",
|
||||
value=f"{player.wara:.1f}",
|
||||
inline=True
|
||||
# Create interactive player view with toggleable statistics
|
||||
self.logger.debug("Creating PlayerStatsView with toggleable statistics")
|
||||
view = PlayerStatsView(
|
||||
player=player_with_team,
|
||||
season=search_season,
|
||||
batting_stats=batting_stats,
|
||||
pitching_stats=pitching_stats,
|
||||
user_id=interaction.user.id
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Player ID",
|
||||
value=str(player.id),
|
||||
inline=True
|
||||
)
|
||||
|
||||
# All positions if multiple
|
||||
if len(player.positions) > 1:
|
||||
embed.add_field(
|
||||
name="Positions",
|
||||
value=", ".join(player.positions),
|
||||
inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Season",
|
||||
value=str(season),
|
||||
inline=True
|
||||
)
|
||||
# Get initial embed with stats hidden
|
||||
embed = await view.get_initial_embed()
|
||||
|
||||
# Add injury rating if available
|
||||
if player.injury_rating:
|
||||
embed.add_field(
|
||||
name="Injury Rating",
|
||||
value=player.injury_rating,
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Add batting stats if available
|
||||
if batting_stats:
|
||||
self.logger.debug("Adding batting statistics to embed")
|
||||
batting_value = (
|
||||
f"**AVG/OBP/SLG:** {batting_stats.avg:.3f}/{batting_stats.obp:.3f}/{batting_stats.slg:.3f}\n"
|
||||
f"**OPS:** {batting_stats.ops:.3f} | **wOBA:** {batting_stats.woba:.3f}\n"
|
||||
f"**HR:** {batting_stats.homerun} | **RBI:** {batting_stats.rbi} | **R:** {batting_stats.run}\n"
|
||||
f"**AB:** {batting_stats.ab} | **H:** {batting_stats.hit} | **BB:** {batting_stats.bb} | **SO:** {batting_stats.so}"
|
||||
)
|
||||
embed.add_field(
|
||||
name="⚾ Batting Stats",
|
||||
value=batting_value,
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add pitching stats if available
|
||||
if pitching_stats:
|
||||
self.logger.debug("Adding pitching statistics to embed")
|
||||
ip = pitching_stats.innings_pitched
|
||||
pitching_value = (
|
||||
f"**W-L:** {pitching_stats.win}-{pitching_stats.loss} | **ERA:** {pitching_stats.era:.2f}\n"
|
||||
f"**WHIP:** {pitching_stats.whip:.2f} | **IP:** {ip:.1f}\n"
|
||||
f"**SO:** {pitching_stats.so} | **BB:** {pitching_stats.bb} | **H:** {pitching_stats.hits}\n"
|
||||
f"**GS:** {pitching_stats.gs} | **SV:** {pitching_stats.saves} | **HLD:** {pitching_stats.hold}"
|
||||
)
|
||||
embed.add_field(
|
||||
name="🥎 Pitching Stats",
|
||||
value=pitching_value,
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add a note if no stats are available
|
||||
if not batting_stats and not pitching_stats:
|
||||
embed.add_field(
|
||||
name="📊 Statistics",
|
||||
value="No statistics available for this season.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Set player card as main image
|
||||
if player.image:
|
||||
embed.set_image(url=player.image)
|
||||
self.logger.debug("Player card image added to embed", image_url=player.image)
|
||||
|
||||
# Set thumbnail with priority: fancycard → headshot → team logo
|
||||
thumbnail_url = None
|
||||
thumbnail_source = None
|
||||
|
||||
if hasattr(player, 'vanity_card') and player.vanity_card:
|
||||
thumbnail_url = player.vanity_card
|
||||
thumbnail_source = "fancycard"
|
||||
elif hasattr(player, 'headshot') and player.headshot:
|
||||
thumbnail_url = player.headshot
|
||||
thumbnail_source = "headshot"
|
||||
elif hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail:
|
||||
thumbnail_url = player.team.thumbnail
|
||||
thumbnail_source = "team logo"
|
||||
|
||||
if thumbnail_url:
|
||||
embed.set_thumbnail(url=thumbnail_url)
|
||||
self.logger.debug(f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url)
|
||||
|
||||
# Footer with player ID and additional info
|
||||
footer_text = f"Player ID: {player.id}"
|
||||
if batting_stats and pitching_stats:
|
||||
footer_text += " • Two-way player"
|
||||
embed.set_footer(text=footer_text)
|
||||
|
||||
return embed
|
||||
# Send with interactive view
|
||||
await interaction.followup.send(embed=embed, view=view)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
|
||||
@ -3,8 +3,8 @@ Injury model for tracking player injuries
|
||||
|
||||
Represents an injury record with game timeline and status information.
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import Field
|
||||
from typing import Optional, Any, Dict
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from models.base import SBABaseModel
|
||||
|
||||
@ -19,6 +19,26 @@ class Injury(SBABaseModel):
|
||||
player_id: int = Field(..., description="Player ID who is injured")
|
||||
total_games: int = Field(..., description="Total games player will be out")
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def extract_player_id(cls, data: Any) -> Any:
|
||||
"""
|
||||
Extract player_id from nested player object if present.
|
||||
|
||||
The API returns injuries with a nested 'player' object:
|
||||
{'id': 123, 'player': {'id': 456, ...}, ...}
|
||||
|
||||
This validator extracts the player ID before validation:
|
||||
{'id': 123, 'player_id': 456, ...}
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
# If player_id is missing but player object exists, extract it
|
||||
if 'player_id' not in data and 'player' in data:
|
||||
if isinstance(data['player'], dict) and 'id' in data['player']:
|
||||
data['player_id'] = data['player']['id']
|
||||
|
||||
return data
|
||||
|
||||
# Injury timeline
|
||||
start_week: int = Field(..., description="Week injury started")
|
||||
start_game: int = Field(..., description="Game number injury started (1-4)")
|
||||
|
||||
@ -155,13 +155,22 @@ class InjuryService(BaseService[Injury]):
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
injury = await self.create(injury_data)
|
||||
if injury:
|
||||
logger.info(f"Created injury for player {player_id}: {total_games} games")
|
||||
return injury
|
||||
# Call the API to create the injury
|
||||
client = await self.get_client()
|
||||
response = await client.post(self.endpoint, injury_data)
|
||||
|
||||
logger.error(f"Failed to create injury for player {player_id}")
|
||||
return None
|
||||
if not response:
|
||||
logger.error(f"Failed to create injury for player {player_id}: No response from API")
|
||||
return None
|
||||
|
||||
# Merge the request data with the response to ensure all required fields are present
|
||||
# (API may not return all fields that were sent)
|
||||
merged_data = {**injury_data, **response}
|
||||
|
||||
# Create Injury model from merged data
|
||||
injury = Injury.from_api_data(merged_data)
|
||||
logger.info(f"Created injury for player {player_id}: {total_games} games")
|
||||
return injury
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating injury for player {player_id}: {e}")
|
||||
|
||||
@ -4,7 +4,7 @@ Base View Classes for Discord Bot v2.0
|
||||
Provides foundational view components with consistent styling and behavior.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Any, Callable, Awaitable
|
||||
from typing import List, Optional, Any, Callable, Awaitable, Union
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import discord
|
||||
@ -21,20 +21,22 @@ class BaseView(discord.ui.View):
|
||||
*,
|
||||
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:
|
||||
if self.user_id is None and self.responders is None:
|
||||
return True
|
||||
|
||||
if interaction.user.id != self.user_id:
|
||||
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
|
||||
@ -95,14 +97,15 @@ class ConfirmationView(BaseView):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
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, logger_name=f'{__name__}.ConfirmationView')
|
||||
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
|
||||
|
||||
355
views/modals.py
355
views/modals.py
@ -485,4 +485,357 @@ def validate_season(season: str) -> bool:
|
||||
season_num = int(season)
|
||||
return 1 <= season_num <= 50
|
||||
except ValueError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
class BatterInjuryModal(BaseModal):
|
||||
"""Modal for collecting current week/game when logging batter injury."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
player: 'Player',
|
||||
injury_games: int,
|
||||
season: int,
|
||||
*,
|
||||
timeout: Optional[float] = 300.0
|
||||
):
|
||||
"""
|
||||
Initialize batter injury modal.
|
||||
|
||||
Args:
|
||||
player: Player object for the injured batter
|
||||
injury_games: Injury games from roll
|
||||
season: Current season number
|
||||
timeout: Modal timeout in seconds
|
||||
"""
|
||||
super().__init__(title=f"Batter Injury - {player.name}", timeout=timeout)
|
||||
|
||||
self.player = player
|
||||
self.injury_games = injury_games
|
||||
self.season = season
|
||||
|
||||
# Current week input
|
||||
self.current_week = discord.ui.TextInput(
|
||||
label="Current Week",
|
||||
placeholder="Enter current week number (e.g., 5)",
|
||||
required=True,
|
||||
max_length=2,
|
||||
style=discord.TextStyle.short
|
||||
)
|
||||
|
||||
# Current game input
|
||||
self.current_game = discord.ui.TextInput(
|
||||
label="Current Game",
|
||||
placeholder="Enter current game number (1-4)",
|
||||
required=True,
|
||||
max_length=1,
|
||||
style=discord.TextStyle.short
|
||||
)
|
||||
|
||||
self.add_item(self.current_week)
|
||||
self.add_item(self.current_game)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
"""Handle batter injury input and log injury."""
|
||||
from services.player_service import player_service
|
||||
from services.injury_service import injury_service
|
||||
import math
|
||||
|
||||
# Validate current week
|
||||
try:
|
||||
week = int(self.current_week.value)
|
||||
if week < 1 or week > 18:
|
||||
raise ValueError("Week must be between 1 and 18")
|
||||
except ValueError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Invalid Week",
|
||||
description="Current week must be a number between 1 and 18."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Validate current game
|
||||
try:
|
||||
game = int(self.current_game.value)
|
||||
if game < 1 or game > 4:
|
||||
raise ValueError("Game must be between 1 and 4")
|
||||
except ValueError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Invalid Game",
|
||||
description="Current game must be a number between 1 and 4."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Calculate injury dates
|
||||
out_weeks = math.floor(self.injury_games / 4)
|
||||
out_games = self.injury_games % 4
|
||||
|
||||
return_week = week + out_weeks
|
||||
return_game = game + 1 + out_games
|
||||
|
||||
if return_game > 4:
|
||||
return_week += 1
|
||||
return_game -= 4
|
||||
|
||||
# Adjust start date if injury starts after game 4
|
||||
start_week = week if game != 4 else week + 1
|
||||
start_game = game + 1 if game != 4 else 1
|
||||
|
||||
return_date = f'w{return_week:02d}g{return_game}'
|
||||
|
||||
# Create injury record
|
||||
try:
|
||||
injury = await injury_service.create_injury(
|
||||
season=self.season,
|
||||
player_id=self.player.id,
|
||||
total_games=self.injury_games,
|
||||
start_week=start_week,
|
||||
start_game=start_game,
|
||||
end_week=return_week,
|
||||
end_game=return_game
|
||||
)
|
||||
|
||||
if not injury:
|
||||
raise ValueError("Failed to create injury record")
|
||||
|
||||
# Update player's il_return field
|
||||
await player_service.update_player(self.player.id, {'il_return': return_date})
|
||||
|
||||
# Success response
|
||||
embed = EmbedTemplate.success(
|
||||
title="Injury Logged",
|
||||
description=f"{self.player.name}'s injury has been logged."
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Duration",
|
||||
value=f"{self.injury_games} game{'s' if self.injury_games > 1 else ''}",
|
||||
inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Return Date",
|
||||
value=return_date,
|
||||
inline=True
|
||||
)
|
||||
|
||||
if self.player.team:
|
||||
embed.add_field(
|
||||
name="Team",
|
||||
value=f"{self.player.team.lname} ({self.player.team.abbrev})",
|
||||
inline=False
|
||||
)
|
||||
|
||||
self.is_submitted = True
|
||||
self.result = {
|
||||
'injury_id': injury.id,
|
||||
'total_games': self.injury_games,
|
||||
'return_date': return_date
|
||||
}
|
||||
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to create batter injury", error=e, player_id=self.player.id)
|
||||
embed = EmbedTemplate.error(
|
||||
title="Error",
|
||||
description="Failed to log the injury. Please try again or contact an administrator."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
|
||||
class PitcherRestModal(BaseModal):
|
||||
"""Modal for collecting pitcher rest games when logging injury."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
player: 'Player',
|
||||
injury_games: int,
|
||||
season: int,
|
||||
*,
|
||||
timeout: Optional[float] = 300.0
|
||||
):
|
||||
"""
|
||||
Initialize pitcher rest modal.
|
||||
|
||||
Args:
|
||||
player: Player object for the injured pitcher
|
||||
injury_games: Base injury games from roll
|
||||
season: Current season number
|
||||
timeout: Modal timeout in seconds
|
||||
"""
|
||||
super().__init__(title=f"Pitcher Rest - {player.name}", timeout=timeout)
|
||||
|
||||
self.player = player
|
||||
self.injury_games = injury_games
|
||||
self.season = season
|
||||
|
||||
# Current week input
|
||||
self.current_week = discord.ui.TextInput(
|
||||
label="Current Week",
|
||||
placeholder="Enter current week number (e.g., 5)",
|
||||
required=True,
|
||||
max_length=2,
|
||||
style=discord.TextStyle.short
|
||||
)
|
||||
|
||||
# Current game input
|
||||
self.current_game = discord.ui.TextInput(
|
||||
label="Current Game",
|
||||
placeholder="Enter current game number (1-4)",
|
||||
required=True,
|
||||
max_length=1,
|
||||
style=discord.TextStyle.short
|
||||
)
|
||||
|
||||
# Rest games input
|
||||
self.rest_games = discord.ui.TextInput(
|
||||
label="Pitcher Rest Games",
|
||||
placeholder="Enter number of rest games (0 or more)",
|
||||
required=True,
|
||||
max_length=2,
|
||||
style=discord.TextStyle.short
|
||||
)
|
||||
|
||||
self.add_item(self.current_week)
|
||||
self.add_item(self.current_game)
|
||||
self.add_item(self.rest_games)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
"""Handle pitcher rest input and log injury."""
|
||||
from services.player_service import player_service
|
||||
from services.injury_service import injury_service
|
||||
from models.injury import Injury
|
||||
import math
|
||||
|
||||
# Validate current week
|
||||
try:
|
||||
week = int(self.current_week.value)
|
||||
if week < 1 or week > 18:
|
||||
raise ValueError("Week must be between 1 and 18")
|
||||
except ValueError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Invalid Week",
|
||||
description="Current week must be a number between 1 and 18."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Validate current game
|
||||
try:
|
||||
game = int(self.current_game.value)
|
||||
if game < 1 or game > 4:
|
||||
raise ValueError("Game must be between 1 and 4")
|
||||
except ValueError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Invalid Game",
|
||||
description="Current game must be a number between 1 and 4."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Validate rest games
|
||||
try:
|
||||
rest = int(self.rest_games.value)
|
||||
if rest < 0:
|
||||
raise ValueError("Rest games cannot be negative")
|
||||
except ValueError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Invalid Rest Games",
|
||||
description="Rest games must be a non-negative number."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Calculate total injury
|
||||
total_injury_games = self.injury_games + rest
|
||||
|
||||
# Calculate injury dates
|
||||
out_weeks = math.floor(total_injury_games / 4)
|
||||
out_games = total_injury_games % 4
|
||||
|
||||
return_week = week + out_weeks
|
||||
return_game = game + 1 + out_games
|
||||
|
||||
if return_game > 4:
|
||||
return_week += 1
|
||||
return_game -= 4
|
||||
|
||||
# Adjust start date if injury starts after game 4
|
||||
start_week = week if game != 4 else week + 1
|
||||
start_game = game + 1 if game != 4 else 1
|
||||
|
||||
return_date = f'w{return_week:02d}g{return_game}'
|
||||
|
||||
# Create injury record
|
||||
try:
|
||||
injury = await injury_service.create_injury(
|
||||
season=self.season,
|
||||
player_id=self.player.id,
|
||||
total_games=total_injury_games,
|
||||
start_week=start_week,
|
||||
start_game=start_game,
|
||||
end_week=return_week,
|
||||
end_game=return_game
|
||||
)
|
||||
|
||||
if not injury:
|
||||
raise ValueError("Failed to create injury record")
|
||||
|
||||
# Update player's il_return field
|
||||
await player_service.update_player(self.player.id, {'il_return': return_date})
|
||||
|
||||
# Success response
|
||||
embed = EmbedTemplate.success(
|
||||
title="Injury Logged",
|
||||
description=f"{self.player.name}'s injury has been logged."
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Base Injury",
|
||||
value=f"{self.injury_games} game{'s' if self.injury_games > 1 else ''}",
|
||||
inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Rest Requirement",
|
||||
value=f"{rest} game{'s' if rest > 1 else ''}",
|
||||
inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Total Duration",
|
||||
value=f"{total_injury_games} game{'s' if total_injury_games > 1 else ''}",
|
||||
inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Return Date",
|
||||
value=return_date,
|
||||
inline=True
|
||||
)
|
||||
|
||||
if self.player.team:
|
||||
embed.add_field(
|
||||
name="Team",
|
||||
value=f"{self.player.team.lname} ({self.player.team.abbrev})",
|
||||
inline=False
|
||||
)
|
||||
|
||||
self.is_submitted = True
|
||||
self.result = {
|
||||
'injury_id': injury.id,
|
||||
'total_games': total_injury_games,
|
||||
'return_date': return_date
|
||||
}
|
||||
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to create pitcher injury", error=e, player_id=self.player.id)
|
||||
embed = EmbedTemplate.error(
|
||||
title="Error",
|
||||
description="Failed to log the injury. Please try again or contact an administrator."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
395
views/players.py
Normal file
395
views/players.py
Normal file
@ -0,0 +1,395 @@
|
||||
"""
|
||||
Player View Components
|
||||
|
||||
Interactive Discord UI components for player information display with toggleable statistics.
|
||||
"""
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from utils.logging import get_contextual_logger
|
||||
from views.base import BaseView
|
||||
from views.embeds import EmbedTemplate, EmbedColors
|
||||
from models.team import RosterType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.player import Player
|
||||
from models.batting_stats import BattingStats
|
||||
from models.pitching_stats import PitchingStats
|
||||
|
||||
|
||||
class PlayerStatsView(BaseView):
|
||||
"""
|
||||
Interactive view for player information with toggleable batting and pitching statistics.
|
||||
|
||||
Features:
|
||||
- Basic player info always visible
|
||||
- Batting stats hidden by default, toggled with button
|
||||
- Pitching stats hidden by default, toggled with button
|
||||
- Buttons only appear if corresponding stats exist
|
||||
- User restriction - only command caller can toggle
|
||||
- 5 minute timeout with graceful cleanup
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
player: 'Player',
|
||||
season: int,
|
||||
batting_stats: Optional['BattingStats'] = None,
|
||||
pitching_stats: Optional['PitchingStats'] = None,
|
||||
user_id: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
Initialize the player stats view.
|
||||
|
||||
Args:
|
||||
player: Player model with basic information
|
||||
season: Season for statistics display
|
||||
batting_stats: Batting statistics (if available)
|
||||
pitching_stats: Pitching statistics (if available)
|
||||
user_id: Discord user ID who can interact with this view
|
||||
"""
|
||||
super().__init__(timeout=300.0, user_id=user_id, logger_name=f'{__name__}.PlayerStatsView')
|
||||
|
||||
self.player = player
|
||||
self.season = season
|
||||
self.batting_stats = batting_stats
|
||||
self.pitching_stats = pitching_stats
|
||||
self.show_batting = False
|
||||
self.show_pitching = False
|
||||
|
||||
# Only show batting button if stats are available
|
||||
if not batting_stats:
|
||||
self.remove_item(self.toggle_batting_button)
|
||||
self.logger.debug("No batting stats available, batting button hidden")
|
||||
|
||||
# Only show pitching button if stats are available
|
||||
if not pitching_stats:
|
||||
self.remove_item(self.toggle_pitching_button)
|
||||
self.logger.debug("No pitching stats available, pitching button hidden")
|
||||
|
||||
self.logger.info("PlayerStatsView initialized",
|
||||
player_id=player.id,
|
||||
player_name=player.name,
|
||||
season=season,
|
||||
has_batting=bool(batting_stats),
|
||||
has_pitching=bool(pitching_stats),
|
||||
user_id=user_id)
|
||||
|
||||
@discord.ui.button(
|
||||
label="Show Batting Stats",
|
||||
style=discord.ButtonStyle.primary,
|
||||
emoji="💥",
|
||||
row=0
|
||||
)
|
||||
async def toggle_batting_button(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
button: discord.ui.Button
|
||||
):
|
||||
"""Toggle batting statistics visibility."""
|
||||
self.increment_interaction_count()
|
||||
self.show_batting = not self.show_batting
|
||||
|
||||
# Update button label
|
||||
button.label = "Hide Batting Stats" if self.show_batting else "Show Batting Stats"
|
||||
|
||||
self.logger.info("Batting stats toggled",
|
||||
player_id=self.player.id,
|
||||
show_batting=self.show_batting,
|
||||
user_id=interaction.user.id)
|
||||
|
||||
# Rebuild and update embed
|
||||
await self._update_embed(interaction)
|
||||
|
||||
@discord.ui.button(
|
||||
label="Show Pitching Stats",
|
||||
style=discord.ButtonStyle.primary,
|
||||
emoji="⚾",
|
||||
row=0
|
||||
)
|
||||
async def toggle_pitching_button(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
button: discord.ui.Button
|
||||
):
|
||||
"""Toggle pitching statistics visibility."""
|
||||
self.increment_interaction_count()
|
||||
self.show_pitching = not self.show_pitching
|
||||
|
||||
# Update button label
|
||||
button.label = "Hide Pitching Stats" if self.show_pitching else "Show Pitching Stats"
|
||||
|
||||
self.logger.info("Pitching stats toggled",
|
||||
player_id=self.player.id,
|
||||
show_pitching=self.show_pitching,
|
||||
user_id=interaction.user.id)
|
||||
|
||||
# Rebuild and update embed
|
||||
await self._update_embed(interaction)
|
||||
|
||||
async def _update_embed(self, interaction: discord.Interaction):
|
||||
"""
|
||||
Rebuild the player embed with current visibility settings and update the message.
|
||||
|
||||
Args:
|
||||
interaction: Discord interaction from button click
|
||||
"""
|
||||
try:
|
||||
# Create embed with current visibility state
|
||||
embed = await self._create_player_embed()
|
||||
|
||||
# Update the message with new embed
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
|
||||
self.logger.debug("Embed updated successfully",
|
||||
show_batting=self.show_batting,
|
||||
show_pitching=self.show_pitching)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to update embed", error=str(e), exc_info=True)
|
||||
|
||||
# Try to send error message
|
||||
try:
|
||||
error_embed = EmbedTemplate.error(
|
||||
title="Update Failed",
|
||||
description="Failed to update player statistics. Please try again."
|
||||
)
|
||||
await interaction.response.send_message(embed=error_embed, ephemeral=True)
|
||||
except Exception:
|
||||
self.logger.error("Failed to send error message", exc_info=True)
|
||||
|
||||
async def _create_player_embed(self) -> discord.Embed:
|
||||
"""
|
||||
Create player embed with current visibility settings.
|
||||
|
||||
Returns:
|
||||
Discord embed with player information and visible stats
|
||||
"""
|
||||
player = self.player
|
||||
season = self.season
|
||||
|
||||
# Determine embed color based on team
|
||||
embed_color = EmbedColors.PRIMARY
|
||||
if hasattr(player, 'team') and player.team and hasattr(player.team, 'color'):
|
||||
try:
|
||||
# Convert hex color string to int
|
||||
embed_color = int(player.team.color, 16)
|
||||
except (ValueError, TypeError):
|
||||
embed_color = EmbedColors.PRIMARY
|
||||
|
||||
# Create base embed with player name as title
|
||||
# Add injury indicator emoji if player is injured
|
||||
title = f"🤕 {player.name}" if player.il_return is not None else player.name
|
||||
|
||||
embed = EmbedTemplate.create_base_embed(
|
||||
title=title,
|
||||
color=embed_color
|
||||
)
|
||||
|
||||
# Basic info section (always visible)
|
||||
embed.add_field(
|
||||
name="Position",
|
||||
value=player.primary_position,
|
||||
inline=True
|
||||
)
|
||||
|
||||
if hasattr(player, 'team') and player.team:
|
||||
embed.add_field(
|
||||
name="Team",
|
||||
value=f"{player.team.abbrev} - {player.team.sname}",
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Add Major League affiliate if this is a Minor League team
|
||||
if player.team.roster_type() == RosterType.MINOR_LEAGUE:
|
||||
major_affiliate = player.team.get_major_league_affiliate()
|
||||
if major_affiliate:
|
||||
embed.add_field(
|
||||
name="Major Affiliate",
|
||||
value=major_affiliate,
|
||||
inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="sWAR",
|
||||
value=f"{player.wara:.1f}",
|
||||
inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Player ID",
|
||||
value=str(player.id),
|
||||
inline=True
|
||||
)
|
||||
|
||||
# All positions if multiple
|
||||
if len(player.positions) > 1:
|
||||
embed.add_field(
|
||||
name="Positions",
|
||||
value=", ".join(player.positions),
|
||||
inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Season",
|
||||
value=str(season),
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Always show injury rating
|
||||
embed.add_field(
|
||||
name="Injury Rating",
|
||||
value=player.injury_rating or "N/A",
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Show injury return date only if player is currently injured
|
||||
if player.il_return:
|
||||
embed.add_field(
|
||||
name="Injury Return",
|
||||
value=player.il_return,
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Add batting stats if visible and available
|
||||
if self.show_batting and self.batting_stats:
|
||||
embed.add_field(name='', value='', inline=False)
|
||||
|
||||
self.logger.debug("Adding batting statistics to embed")
|
||||
batting_stats = self.batting_stats
|
||||
|
||||
rate_stats = (
|
||||
"```\n"
|
||||
"╭─────────────╮\n"
|
||||
f"│ AVG {batting_stats.avg:.3f} │\n"
|
||||
f"│ OBP {batting_stats.obp:.3f} │\n"
|
||||
f"│ SLG {batting_stats.slg:.3f} │\n"
|
||||
f"│ OPS {batting_stats.ops:.3f} │\n"
|
||||
f"│ wOBA {batting_stats.woba:.3f} │\n"
|
||||
"╰─────────────╯\n"
|
||||
"```"
|
||||
)
|
||||
embed.add_field(
|
||||
name="Rate Stats",
|
||||
value=rate_stats,
|
||||
inline=True
|
||||
)
|
||||
|
||||
count_stats = (
|
||||
"```\n"
|
||||
"╭───────────╮\n"
|
||||
f"│ HR {batting_stats.homerun:>3} │\n"
|
||||
f"│ RBI {batting_stats.rbi:>3} │\n"
|
||||
f"│ R {batting_stats.run:>3} │\n"
|
||||
f"│ AB {batting_stats.ab:>4} │\n"
|
||||
f"│ H {batting_stats.hit:>4} │\n"
|
||||
f"│ BB {batting_stats.bb:>3} │\n"
|
||||
f"│ SO {batting_stats.so:>3} │\n"
|
||||
"╰───────────╯\n"
|
||||
"```"
|
||||
)
|
||||
embed.add_field(
|
||||
name='Counting Stats',
|
||||
value=count_stats,
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Add pitching stats if visible and available
|
||||
if self.show_pitching and self.pitching_stats:
|
||||
embed.add_field(name='', value='', inline=False)
|
||||
|
||||
self.logger.debug("Adding pitching statistics to embed")
|
||||
pitching_stats = self.pitching_stats
|
||||
ip = pitching_stats.innings_pitched
|
||||
|
||||
record_stats = (
|
||||
"```\n"
|
||||
"╭─────────────╮\n"
|
||||
f"│ G-GS {pitching_stats.games:>2}-{pitching_stats.gs:<2} │\n"
|
||||
f"│ W-L {pitching_stats.win:>2}-{pitching_stats.loss:<2} │\n"
|
||||
f"│ H-SV {pitching_stats.hold:>2}-{pitching_stats.saves:<2} │\n"
|
||||
f"│ ERA {pitching_stats.era:>5.2f} │\n"
|
||||
f"│ WHIP {pitching_stats.whip:>5.2f} │\n"
|
||||
"╰─────────────╯\n"
|
||||
"```"
|
||||
)
|
||||
embed.add_field(
|
||||
name="Record Stats",
|
||||
value=record_stats,
|
||||
inline=True
|
||||
)
|
||||
|
||||
strikeout_stats = (
|
||||
"```\n"
|
||||
"╭──────────╮\n"
|
||||
f"│ IP{ip:>6.1f} │\n"
|
||||
f"│ SO {pitching_stats.so:>3} │\n"
|
||||
f"│ BB {pitching_stats.bb:>3} │\n"
|
||||
f"│ H {pitching_stats.hits:>3} │\n"
|
||||
"╰──────────╯\n"
|
||||
"```"
|
||||
)
|
||||
embed.add_field(
|
||||
name='Counting Stats',
|
||||
value=strikeout_stats,
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Add a note if no stats are visible
|
||||
if not self.show_batting and not self.show_pitching:
|
||||
if self.batting_stats or self.pitching_stats:
|
||||
embed.add_field(
|
||||
name="📊 Statistics",
|
||||
value="Click the buttons below to show statistics.",
|
||||
inline=False
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="📊 Statistics",
|
||||
value="No statistics available for this season.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Set player card as main image
|
||||
if player.image:
|
||||
embed.set_image(url=player.image)
|
||||
self.logger.debug("Player card image added to embed", image_url=player.image)
|
||||
|
||||
# Set thumbnail with priority: fancycard → headshot → team logo
|
||||
thumbnail_url = None
|
||||
thumbnail_source = None
|
||||
|
||||
if hasattr(player, 'vanity_card') and player.vanity_card:
|
||||
thumbnail_url = player.vanity_card
|
||||
thumbnail_source = "fancycard"
|
||||
elif hasattr(player, 'headshot') and player.headshot:
|
||||
thumbnail_url = player.headshot
|
||||
thumbnail_source = "headshot"
|
||||
elif hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail:
|
||||
thumbnail_url = player.team.thumbnail
|
||||
thumbnail_source = "team logo"
|
||||
|
||||
if thumbnail_url:
|
||||
embed.set_thumbnail(url=thumbnail_url)
|
||||
self.logger.debug(f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url)
|
||||
|
||||
# Footer with player ID
|
||||
footer_text = f"Player ID: {player.id}"
|
||||
embed.set_footer(text=footer_text)
|
||||
|
||||
return embed
|
||||
|
||||
async def get_initial_embed(self) -> discord.Embed:
|
||||
"""
|
||||
Get the initial embed with stats hidden.
|
||||
|
||||
Returns:
|
||||
Discord embed with player information, stats hidden by default
|
||||
"""
|
||||
# Ensure stats are hidden for initial display
|
||||
self.show_batting = False
|
||||
self.show_pitching = False
|
||||
|
||||
return await self._create_player_embed()
|
||||
Loading…
Reference in New Issue
Block a user