major-domo-v2/views/modals.py
Cal Corum 2bfc87ac1b CLAUDE: Fix injury roll validation to support playoff weeks
Fixed bug where injury rolls during playoff weeks (19-21) were being rejected
with "weeks 1-18 only" error message.

Changes:
- Updated BatterInjuryModal and PitcherRestModal week validation
- Now uses config.weeks_per_season + config.playoff_weeks_per_season for max week
- Added dynamic game validation based on playoff round:
  * Regular season (weeks 1-18): 4 games per week
  * Playoff round 1 (week 19): 5 games
  * Playoff round 2 (week 20): 7 games
  * Playoff round 3 (week 21): 7 games
- Replaced hardcoded values with config-based calculations

Config values used:
- weeks_per_season (18)
- playoff_weeks_per_season (3)
- games_per_week (4)
- playoff_round_one_games (5)
- playoff_round_two_games (7)
- playoff_round_three_games (7)

Now injuries can be properly logged during all phases of the season including
playoffs with correct game validation for each round.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 00:56:39 -05:00

873 lines
30 KiB
Python

"""
Modal Components for Discord Bot v2.0
Interactive forms and input dialogs for collecting user data.
"""
from typing import Optional, Callable, Awaitable, Dict, Any, List
import re
import discord
from discord.ext import commands
from .embeds import EmbedTemplate, EmbedColors
from utils.logging import get_contextual_logger
class BaseModal(discord.ui.Modal):
"""Base modal class with consistent error handling and validation."""
def __init__(
self,
*,
title: str,
timeout: Optional[float] = 300.0,
custom_id: Optional[str] = None
):
kwargs = {"title": title, "timeout": timeout}
if custom_id is not None:
kwargs["custom_id"] = custom_id
super().__init__(**kwargs)
self.logger = get_contextual_logger(f'{__name__}.{self.__class__.__name__}')
self.result: Optional[Dict[str, Any]] = None
self.is_submitted = False
async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:
"""Handle modal errors."""
self.logger.error("Modal error occurred",
error=error,
modal_title=self.title,
user_id=interaction.user.id)
try:
embed = EmbedTemplate.error(
title="Form Error",
description="An error occurred while processing your form. Please try again."
)
if not interaction.response.is_done():
await interaction.response.send_message(embed=embed, ephemeral=True)
else:
await interaction.followup.send(embed=embed, ephemeral=True)
except Exception as e:
self.logger.error("Failed to send error message", error=e)
def validate_input(self, field_name: str, value: str, validators: Optional[List[Callable[[str], bool]]] = None) -> tuple[bool, str]:
"""Validate input field with optional custom validators."""
if not value.strip():
return False, f"{field_name} cannot be empty."
if validators:
for validator in validators:
try:
if not validator(value):
return False, f"Invalid {field_name} format."
except Exception:
return False, f"Validation error for {field_name}."
return True, ""
class PlayerSearchModal(BaseModal):
"""Modal for collecting detailed player search criteria."""
def __init__(self, *, timeout: Optional[float] = 300.0):
super().__init__(title="Player Search", timeout=timeout)
self.player_name = discord.ui.TextInput(
label="Player Name",
placeholder="Enter player name (required)",
required=True,
max_length=100
)
self.position = discord.ui.TextInput(
label="Position",
placeholder="e.g., SS, OF, P (optional)",
required=False,
max_length=10
)
self.team = discord.ui.TextInput(
label="Team",
placeholder="Team abbreviation (optional)",
required=False,
max_length=5
)
self.season = discord.ui.TextInput(
label="Season",
placeholder="Season number (optional)",
required=False,
max_length=4
)
self.add_item(self.player_name)
self.add_item(self.position)
self.add_item(self.team)
self.add_item(self.season)
async def on_submit(self, interaction: discord.Interaction):
"""Handle form submission."""
# Validate season if provided
season_value = None
if self.season.value:
try:
season_value = int(self.season.value)
if season_value < 1 or season_value > 50: # Reasonable bounds
raise ValueError("Season out of range")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Season",
description="Season must be a valid number between 1 and 50."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Store results
self.result = {
'name': self.player_name.value.strip(),
'position': self.position.value.strip() if self.position.value else None,
'team': self.team.value.strip().upper() if self.team.value else None,
'season': season_value
}
self.is_submitted = True
# Acknowledge submission
embed = EmbedTemplate.info(
title="Search Submitted",
description=f"Searching for player: **{self.result['name']}**"
)
if self.result['position']:
embed.add_field(name="Position", value=self.result['position'], inline=True)
if self.result['team']:
embed.add_field(name="Team", value=self.result['team'], inline=True)
if self.result['season']:
embed.add_field(name="Season", value=str(self.result['season']), inline=True)
await interaction.response.send_message(embed=embed, ephemeral=True)
class TeamSearchModal(BaseModal):
"""Modal for collecting team search criteria."""
def __init__(self, *, timeout: Optional[float] = 300.0):
super().__init__(title="Team Search", timeout=timeout)
self.team_input = discord.ui.TextInput(
label="Team Name or Abbreviation",
placeholder="Enter team name or abbreviation",
required=True,
max_length=50
)
self.season = discord.ui.TextInput(
label="Season",
placeholder="Season number (optional)",
required=False,
max_length=4
)
self.add_item(self.team_input)
self.add_item(self.season)
async def on_submit(self, interaction: discord.Interaction):
"""Handle form submission."""
# Validate season if provided
season_value = None
if self.season.value:
try:
season_value = int(self.season.value)
if season_value < 1 or season_value > 50:
raise ValueError("Season out of range")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Season",
description="Season must be a valid number between 1 and 50."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Store results
self.result = {
'team': self.team_input.value.strip(),
'season': season_value
}
self.is_submitted = True
# Acknowledge submission
embed = EmbedTemplate.info(
title="Search Submitted",
description=f"Searching for team: **{self.result['team']}**"
)
if self.result['season']:
embed.add_field(name="Season", value=str(self.result['season']), inline=True)
await interaction.response.send_message(embed=embed, ephemeral=True)
class FeedbackModal(BaseModal):
"""Modal for collecting user feedback."""
def __init__(
self,
*,
timeout: Optional[float] = 600.0,
submit_callback: Optional[Callable[[Dict[str, Any]], Awaitable[bool]]] = None
):
super().__init__(title="Submit Feedback", timeout=timeout)
self.submit_callback = submit_callback
self.feedback_type = discord.ui.TextInput(
label="Feedback Type",
placeholder="e.g., Bug Report, Feature Request, General",
required=True,
max_length=50
)
self.subject = discord.ui.TextInput(
label="Subject",
placeholder="Brief description of your feedback",
required=True,
max_length=100
)
self.description = discord.ui.TextInput(
label="Description",
placeholder="Detailed description of your feedback",
style=discord.TextStyle.paragraph,
required=True,
max_length=2000
)
self.contact = discord.ui.TextInput(
label="Contact Info (Optional)",
placeholder="How to reach you for follow-up",
required=False,
max_length=100
)
self.add_item(self.feedback_type)
self.add_item(self.subject)
self.add_item(self.description)
self.add_item(self.contact)
async def on_submit(self, interaction: discord.Interaction):
"""Handle feedback submission."""
# Store results
self.result = {
'type': self.feedback_type.value.strip(),
'subject': self.subject.value.strip(),
'description': self.description.value.strip(),
'contact': self.contact.value.strip() if self.contact.value else None,
'user_id': interaction.user.id,
'username': str(interaction.user),
'submitted_at': discord.utils.utcnow()
}
self.is_submitted = True
# Process feedback
if self.submit_callback:
try:
success = await self.submit_callback(self.result)
if success:
embed = EmbedTemplate.success(
title="Feedback Submitted",
description="Thank you for your feedback! We'll review it shortly."
)
else:
embed = EmbedTemplate.error(
title="Submission Failed",
description="Failed to submit feedback. Please try again later."
)
except Exception as e:
self.logger.error("Feedback submission error", error=e)
embed = EmbedTemplate.error(
title="Submission Error",
description="An error occurred while submitting feedback."
)
else:
embed = EmbedTemplate.success(
title="Feedback Received",
description="Your feedback has been recorded."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
class ConfigurationModal(BaseModal):
"""Modal for configuration settings with validation."""
def __init__(
self,
current_config: Dict[str, Any],
*,
timeout: Optional[float] = 300.0,
save_callback: Optional[Callable[[Dict[str, Any]], Awaitable[bool]]] = None
):
super().__init__(title="Configuration Settings", timeout=timeout)
self.current_config = current_config
self.save_callback = save_callback
# Add configuration fields (customize based on needs)
self.setting1 = discord.ui.TextInput(
label="Setting 1",
placeholder="Enter value for setting 1",
default=str(current_config.get('setting1', '')),
required=False,
max_length=100
)
self.setting2 = discord.ui.TextInput(
label="Setting 2",
placeholder="Enter value for setting 2",
default=str(current_config.get('setting2', '')),
required=False,
max_length=100
)
self.add_item(self.setting1)
self.add_item(self.setting2)
async def on_submit(self, interaction: discord.Interaction):
"""Handle configuration submission."""
# Validate and store new configuration
new_config = self.current_config.copy()
if self.setting1.value:
new_config['setting1'] = self.setting1.value.strip()
if self.setting2.value:
new_config['setting2'] = self.setting2.value.strip()
self.result = new_config
self.is_submitted = True
# Save configuration
if self.save_callback:
try:
success = await self.save_callback(new_config)
if success:
embed = EmbedTemplate.success(
title="Configuration Saved",
description="Your configuration has been updated successfully."
)
else:
embed = EmbedTemplate.error(
title="Save Failed",
description="Failed to save configuration. Please try again."
)
except Exception as e:
self.logger.error("Configuration save error", error=e)
embed = EmbedTemplate.error(
title="Save Error",
description="An error occurred while saving configuration."
)
else:
embed = EmbedTemplate.success(
title="Configuration Updated",
description="Configuration has been updated."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
class CustomInputModal(BaseModal):
"""Flexible modal for custom input collection."""
def __init__(
self,
title: str,
fields: List[Dict[str, Any]],
*,
timeout: Optional[float] = 300.0,
submit_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
):
super().__init__(title=title, timeout=timeout)
self.submit_callback = submit_callback
self.fields_config = fields
# Add text inputs based on field configuration
for field in fields[:5]: # Discord limit of 5 text inputs
text_input = discord.ui.TextInput(
label=field.get('label', 'Field'),
placeholder=field.get('placeholder', ''),
default=field.get('default', ''),
required=field.get('required', False),
max_length=field.get('max_length', 4000),
style=getattr(discord.TextStyle, field.get('style', 'short'))
)
self.add_item(text_input)
async def on_submit(self, interaction: discord.Interaction):
"""Handle custom form submission."""
# Collect all input values
results = {}
for i, item in enumerate(self.children):
if isinstance(item, discord.ui.TextInput):
field_config = self.fields_config[i] if i < len(self.fields_config) else {}
field_key = field_config.get('key', f'field_{i}')
# Apply validation if specified
validators = field_config.get('validators', [])
if validators:
is_valid, error_msg = self.validate_input(
field_config.get('label', 'Field'),
item.value,
validators
)
if not is_valid:
embed = EmbedTemplate.error(
title="Validation Error",
description=error_msg
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
results[field_key] = item.value.strip() if item.value else None
self.result = results
self.is_submitted = True
# Execute callback if provided
if self.submit_callback:
await self.submit_callback(results)
else:
embed = EmbedTemplate.success(
title="Form Submitted",
description="Your form has been submitted successfully."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
# Validation helper functions
def validate_email(email: str) -> bool:
"""Validate email format."""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))
def validate_numeric(value: str) -> bool:
"""Validate numeric input."""
try:
float(value)
return True
except ValueError:
return False
def validate_integer(value: str) -> bool:
"""Validate integer input."""
try:
int(value)
return True
except ValueError:
return False
def validate_team_abbreviation(abbrev: str) -> bool:
"""Validate team abbreviation format."""
return len(abbrev) >= 2 and len(abbrev) <= 5 and abbrev.isalpha()
def validate_season(season: str) -> bool:
"""Validate season number."""
try:
season_num = int(season)
return 1 <= season_num <= 50
except ValueError:
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
from config import get_config
import math
config = get_config()
max_week = config.weeks_per_season + config.playoff_weeks_per_season
# Validate current week
try:
week = int(self.current_week.value)
if week < 1 or week > max_week:
raise ValueError(f"Week must be between 1 and {max_week}")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Week",
description=f"Current week must be a number between 1 and {max_week} (including playoffs)."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Determine max games based on week (regular season vs playoff rounds)
if week <= config.weeks_per_season:
max_game = config.games_per_week
elif week == config.weeks_per_season + 1:
max_game = config.playoff_round_one_games
elif week == config.weeks_per_season + 2:
max_game = config.playoff_round_two_games
elif week == config.weeks_per_season + 3:
max_game = config.playoff_round_three_games
else:
max_game = config.games_per_week # Fallback
# Validate current game
try:
game = int(self.current_game.value)
if game < 1 or game > max_game:
raise ValueError(f"Game must be between 1 and {max_game}")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Game",
description=f"Current game must be a number between 1 and {max_game}."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Calculate injury dates
out_weeks = math.floor(self.injury_games / config.games_per_week)
out_games = self.injury_games % config.games_per_week
return_week = week + out_weeks
return_game = game + 1 + out_games
if return_game > config.games_per_week:
return_week += 1
return_game -= config.games_per_week
# Adjust start date if injury starts after game 4
start_week = week if game != config.games_per_week else week + 1
start_game = game + 1 if game != config.games_per_week 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
from config import get_config
import math
config = get_config()
max_week = config.weeks_per_season + config.playoff_weeks_per_season
# Validate current week
try:
week = int(self.current_week.value)
if week < 1 or week > max_week:
raise ValueError(f"Week must be between 1 and {max_week}")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Week",
description=f"Current week must be a number between 1 and {max_week} (including playoffs)."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Determine max games based on week (regular season vs playoff rounds)
if week <= config.weeks_per_season:
max_game = config.games_per_week
elif week == config.weeks_per_season + 1:
max_game = config.playoff_round_one_games
elif week == config.weeks_per_season + 2:
max_game = config.playoff_round_two_games
elif week == config.weeks_per_season + 3:
max_game = config.playoff_round_three_games
else:
max_game = config.games_per_week # Fallback
# Validate current game
try:
game = int(self.current_game.value)
if game < 1 or game > max_game:
raise ValueError(f"Game must be between 1 and {max_game}")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Game",
description=f"Current game must be a number between 1 and {max_game}."
)
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)