From 2409c27c1d09afa64069fddcc6c3925457275e0d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 16 Oct 2025 00:21:32 -0500 Subject: [PATCH] CLAUDE: Add comprehensive scorecard submission system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full Google Sheets scorecard submission with: - Complete game data extraction (68 play fields, pitching decisions, box score) - Transaction rollback support at 3 states (plays/game/complete) - Duplicate game detection with confirmation dialog - Permission-based submission (GMs only) - Automated results posting to news channel - Automatic standings recalculation - Key plays display with WPA sorting New Components: - Play, Decision, Game models with full validation - SheetsService for Google Sheets integration - GameService, PlayService, DecisionService for data management - ConfirmationView for user confirmations - Discord helper utilities for channel operations Services Enhanced: - StandingsService: Added recalculate_standings() method - CustomCommandsService: Fixed creator endpoint path - Team/Player models: Added helper methods for display Configuration: - Added SHEETS_CREDENTIALS_PATH environment variable - Added SBA_NETWORK_NEWS_CHANNEL and role constants - Enabled pygsheets dependency Documentation: - Comprehensive README updates across all modules - Added command, service, model, and view documentation - Detailed workflow and error handling documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- COMMAND_LIST.md | 2 +- commands/league/README.md | 67 ++++ commands/league/__init__.py | 2 + commands/league/submit_scorecard.py | 528 ++++++++++++++++++++++++++++ config.py | 5 +- constants.py | 12 +- exceptions.py | 5 + models/README.md | 168 +++++++++ models/decision.py | 57 +++ models/player.py | 7 +- models/team.py | 9 + requirements.txt | 2 +- services/README.md | 76 ++++ services/custom_commands_service.py | 2 +- services/decision_service.py | 167 +++++++++ services/game_service.py | 182 ++++++++++ services/play_service.py | 143 ++++++++ services/sheets_service.py | 315 +++++++++++++++++ services/standings_service.py | 32 ++ utils/README.md | 145 +++++++- views/README.md | 91 ++++- views/confirmations.py | 111 ++++++ 22 files changed, 2111 insertions(+), 17 deletions(-) create mode 100644 commands/league/submit_scorecard.py create mode 100644 models/decision.py create mode 100644 services/decision_service.py create mode 100644 services/game_service.py create mode 100644 services/play_service.py create mode 100644 services/sheets_service.py create mode 100644 views/confirmations.py diff --git a/COMMAND_LIST.md b/COMMAND_LIST.md index 9e1f228..c6e4253 100644 --- a/COMMAND_LIST.md +++ b/COMMAND_LIST.md @@ -135,7 +135,7 @@ Roll ballpark weather for a team ### `/charts ` Display a gameplay chart or infographic - **Parameters:** - - `chart_name`: Name of chart (autocomplete enabled) + - `chart_nam e`: Name of chart (autocomplete enabled) --- diff --git a/commands/league/README.md b/commands/league/README.md index 2a7e677..f3495ef 100644 --- a/commands/league/README.md +++ b/commands/league/README.md @@ -46,6 +46,61 @@ This directory contains Discord slash commands related to league-wide informatio - Recent/upcoming game overview - Game completion tracking +### `submit_scorecard.py` +- **Command**: `/submit-scorecard` +- **Description**: Submit Google Sheets scorecards with game results and play-by-play data +- **Parameters**: + - `sheet_url`: Full URL to the Google Sheets scorecard +- **Required Role**: `Season 12 Players` +- **Service Dependencies**: + - `SheetsService` - Google Sheets data extraction + - `game_service` - Game CRUD operations + - `play_service` - Play-by-play data management + - `decision_service` - Pitching decision management + - `standings_service` - Standings recalculation + - `league_service` - Current state retrieval + - `team_service` - Team lookup + - `player_service` - Player lookup for results display +- **Key Features**: + - **Scorecard Validation**: Checks sheet access and version compatibility + - **Permission Control**: Only GMs of playing teams can submit + - **Duplicate Detection**: Identifies already-played games with confirmation dialog + - **Transaction Rollback**: Full rollback support at 3 states: + - `PLAYS_POSTED`: Deletes plays on error + - `GAME_PATCHED`: Wipes game and deletes plays on error + - `COMPLETE`: All data committed successfully + - **Data Extraction**: Reads 68 fields from Playtable, 14 fields from Pitcherstats, box score, and game metadata + - **Results Display**: Rich embed with box score, pitching decisions, and top 3 key plays by WPA + - **Automated Standings**: Triggers standings recalculation after successful submission + - **News Channel Posting**: Automatically posts results to configured channel + +**Workflow (14 Phases)**: +1. Validate scorecard access and version +2. Extract game metadata from Setup tab +3. Lookup teams and match managers +4. Check user permissions (must be GM of one team or bot owner) +5. Check for duplicate games (with confirmation if found) +6. Find scheduled game in database +7. Read play-by-play data (up to 297 plays) +8. Submit plays to database +9. Read box score +10. Update game with scores and managers +11. Read pitching decisions (up to 27 pitchers) +12. Submit decisions to database +13. Create and post results embed to news channel +14. Recalculate league standings + +**Error Handling**: +- User-friendly error messages for common issues +- Graceful rollback on validation errors +- API error parsing for actionable feedback +- Non-critical errors (key plays, standings) don't fail submission + +**Configuration**: +- `sheets_credentials_path` (in config.py): Path to Google service account credentials JSON (set via `SHEETS_CREDENTIALS_PATH` env var) +- `SBA_NETWORK_NEWS_CHANNEL`: Channel name for results posting +- `SBA_PLAYERS_ROLE_NAME`: Role required to submit scorecards + ## Architecture Notes ### Decorator Usage @@ -76,9 +131,21 @@ All commands use the `@logged_command` decorator pattern: - `services.league_service` - `services.standings_service` - `services.schedule_service` +- `services.sheets_service` (NEW) - Google Sheets integration +- `services.game_service` (NEW) - Game management +- `services.play_service` (NEW) - Play-by-play data +- `services.decision_service` (NEW) - Pitching decisions +- `services.team_service` +- `services.player_service` - `utils.decorators.logged_command` +- `utils.discord_helpers` (NEW) - Channel and message utilities +- `utils.team_utils` - `views.embeds.EmbedTemplate` +- `views.confirmations.ConfirmationView` (NEW) - Reusable confirmation dialog - `constants.SBA_CURRENT_SEASON` +- `config.BotConfig.sheets_credentials_path` (NEW) - Google Sheets credentials path +- `constants.SBA_NETWORK_NEWS_CHANNEL` (NEW) +- `constants.SBA_PLAYERS_ROLE_NAME` (NEW) ### Testing Run tests with: `python -m pytest tests/test_commands_league.py -v` \ No newline at end of file diff --git a/commands/league/__init__.py b/commands/league/__init__.py index cfaf8ad..a674b5c 100644 --- a/commands/league/__init__.py +++ b/commands/league/__init__.py @@ -12,6 +12,7 @@ from discord.ext import commands from .info import LeagueInfoCommands from .standings import StandingsCommands from .schedule import ScheduleCommands +from .submit_scorecard import SubmitScorecardCommands logger = logging.getLogger(f'{__name__}.setup_league') @@ -27,6 +28,7 @@ async def setup_league(bot: commands.Bot) -> Tuple[int, int, List[str]]: ("LeagueInfoCommands", LeagueInfoCommands), ("StandingsCommands", StandingsCommands), ("ScheduleCommands", ScheduleCommands), + ("SubmitScorecardCommands", SubmitScorecardCommands), ] successful = 0 diff --git a/commands/league/submit_scorecard.py b/commands/league/submit_scorecard.py new file mode 100644 index 0000000..ffc058b --- /dev/null +++ b/commands/league/submit_scorecard.py @@ -0,0 +1,528 @@ +""" +Scorecard Submission Commands + +Implements the /submit-scorecard command for submitting Google Sheets +scorecards with play-by-play data, pitching decisions, and game results. +""" +from typing import Optional, List + +import discord +from discord.ext import commands +from discord import app_commands + +from services.sheets_service import SheetsService +from services.game_service import game_service +from services.play_service import play_service +from services.decision_service import decision_service +from services.standings_service import standings_service +from services.league_service import league_service +from services.team_service import team_service +from utils.logging import get_contextual_logger +from utils.decorators import logged_command +from utils.discord_helpers import send_to_channel, format_key_plays +from utils.team_utils import get_user_major_league_team +from views.embeds import EmbedTemplate +from views.confirmations import ConfirmationView +from constants import ( + SBA_NETWORK_NEWS_CHANNEL, + SBA_PLAYERS_ROLE_NAME +) +from exceptions import SheetsException, APIException +from models.team import Team +from models.player import Player + +DRY_RUN = False + + +class SubmitScorecardCommands(commands.Cog): + """Scorecard submission command handlers.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.SubmitScorecardCommands') + self.sheets_service = SheetsService() # Will use config automatically + self.logger.info("SubmitScorecardCommands cog initialized") + + @app_commands.command( + name="submit-scorecard", + description="Submit a Google Sheets scorecard with game results and play data" + ) + @app_commands.describe( + sheet_url="Full URL to the Google Sheets scorecard" + ) + @app_commands.checks.has_any_role(SBA_PLAYERS_ROLE_NAME) + @logged_command("/submit-scorecard") + async def submit_scorecard( + self, + interaction: discord.Interaction, + sheet_url: str + ): + """ + Submit scorecard with full transaction rollback support. + + Workflow: + 1. Validate scorecard access and version + 2. Extract game metadata + 3. Check permissions (user must own one of the teams) + 4. Handle duplicate games (with confirmation) + 5. Read play and decision data + 6. Submit data with transaction rollback on errors + 7. Post results to news channel + 8. Recalculate standings + """ + # Always defer since this is a long-running operation + await interaction.response.defer() + + # Track rollback state + rollback_state = None + game_id = None + + try: + # Phase 1: Initial Validation + await interaction.edit_original_response( + content="📋 Accessing scorecard..." + ) + + current = await league_service.get_current_state() + if not current: + raise APIException("Unable to retrieve current league state") + + # Open scorecard + try: + scorecard = await self.sheets_service.open_scorecard(sheet_url) + except SheetsException: + await interaction.edit_original_response( + content="❌ Is that sheet public? I can't access it." + ) + return + + # Read setup data + setup_data = await self.sheets_service.read_setup_data(scorecard) + + # Validate scorecard version + if setup_data['version'] != current.bet_week: + await interaction.edit_original_response( + content=( + f"❌ This scorecard appears out of date (version {setup_data['version']}, " + f"expected {current.bet_week}). Did you create a new card at the start " + f"of the game? If so, contact an admin about this error." + ) + ) + return + + # Phase 2: Team & Manager Lookup + await interaction.edit_original_response( + content="🔍 Looking up teams and managers..." + ) + + away_team = await team_service.get_team_by_abbrev( + setup_data['away_team_abbrev'], + current.season + ) + home_team = await team_service.get_team_by_abbrev( + setup_data['home_team_abbrev'], + current.season + ) + + if not away_team or not home_team: + await interaction.edit_original_response( + content="❌ One or both teams not found in database." + ) + return + + # Match managers + away_manager = self._match_manager( + away_team, + setup_data['away_manager_name'] + ) + home_manager = self._match_manager( + home_team, + setup_data['home_manager_name'] + ) + + # Phase 3: Permission Check + user_team = await get_user_major_league_team( + interaction.user.id, + current.season + ) + + if user_team is None: + # Check if user is bot owner + app_info = await self.bot.application_info() + if interaction.user.id != app_info.owner.id: + await interaction.edit_original_response( + content="❌ Only a GM of the two teams can submit scorecards." + ) + return + elif user_team.id not in [away_team.id, home_team.id]: + await interaction.edit_original_response( + content="❌ Only a GM of the two teams can submit scorecards." + ) + return + + # Phase 4: Duplicate Game Check + duplicate_game = await game_service.find_duplicate_game( + current.season, + setup_data['week'], + setup_data['game_num'], + away_team.id, + home_team.id + ) + + if duplicate_game: + view = ConfirmationView( + responders=[interaction.user], + timeout=30.0 + ) + await interaction.edit_original_response( + content=( + f"⚠️ This game has already been played!\n" + f"Would you like me to wipe the old one and re-submit?" + ), + view=view + ) + await view.wait() + + if view.confirmed: + await interaction.edit_original_response( + content="🗑️ Wiping old game data...", + view=None + ) + + # Delete old data + try: + await play_service.delete_plays_for_game(duplicate_game.id) + except: + pass # May not exist + + try: + await decision_service.delete_decisions_for_game(duplicate_game.id) + except: + pass # May not exist + + await game_service.wipe_game_data(duplicate_game.id) + + else: + await interaction.edit_original_response( + content="❌ You think on it some more and get back to me later.", + view=None + ) + return + + # Phase 5: Find Scheduled Game + scheduled_game = await game_service.find_scheduled_game( + current.season, + setup_data['week'], + away_team.id, + home_team.id + ) + + if not scheduled_game: + await interaction.edit_original_response( + content=( + f"❌ I don't see any games between {away_team.abbrev} and " + f"{home_team.abbrev} in week {setup_data['week']}." + ) + ) + return + + game_id = scheduled_game.id + + # Phase 6: Read Scorecard Data + await interaction.edit_original_response( + content="📊 Reading play-by-play data..." + ) + + plays_data = await self.sheets_service.read_playtable_data(scorecard) + + # Add game_id to each play + for play in plays_data: + play['game_id'] = game_id + + # Phase 7: POST Plays + await interaction.edit_original_response( + content="💾 Submitting plays to database..." + ) + + try: + if not DRY_RUN: + await play_service.create_plays_batch(plays_data) + self.logger.info(f'Posting plays_data (1 and 2): {plays_data[0]} / {plays_data[1]}') + rollback_state = "PLAYS_POSTED" + except APIException as e: + await interaction.edit_original_response( + content=( + f"❌ The following errors were found in your " + f"**wk{setup_data['week']}g{setup_data['game_num']}** scorecard:\n\n" + f"{str(e)}\n\n" + f"Please resolve them and resubmit - thanks!" + ) + ) + return + + # Phase 8: Read Box Score + box_score = await self.sheets_service.read_box_score(scorecard) + + # Phase 9: PATCH Game + await interaction.edit_original_response( + content="⚾ Updating game result..." + ) + + try: + if not DRY_RUN: + await game_service.update_game_result( + game_id, + box_score['away'][0], # Runs + box_score['home'][0], # Runs + away_manager.id, + home_manager.id, + setup_data['game_num'], + sheet_url + ) + self.logger.info(f'Updating game ID {game_id}, {box_score['away'][0]} @ {box_score['home'][0]}, {away_manager.id} vs {home_manager.id}') + rollback_state = "GAME_PATCHED" + except APIException as e: + # Rollback plays + await play_service.delete_plays_for_game(game_id) + await interaction.edit_original_response( + content=f"❌ Unable to log game result. Error: {str(e)}" + ) + return + + # Phase 10: Read Pitching Decisions + decisions_data = await self.sheets_service.read_pitching_decisions(scorecard) + + # Add game metadata to each decision + for decision in decisions_data: + decision['game_id'] = game_id + decision['season'] = current.season + decision['week'] = setup_data['week'] + decision['game_num'] = setup_data['game_num'] + + # Validate WP and LP exist and fetch Player objects + wp, lp, sv, holders, _blown_saves = \ + await decision_service.find_winning_losing_pitchers(decisions_data) + + if wp is None or lp is None: + # Rollback + await game_service.wipe_game_data(game_id) + await play_service.delete_plays_for_game(game_id) + await interaction.edit_original_response( + content="❌ Your card is missing either a Winning Pitcher or Losing Pitcher" + ) + return + + # Phase 11: POST Decisions + await interaction.edit_original_response( + content="🎯 Submitting pitching decisions..." + ) + + try: + if not DRY_RUN: + await decision_service.create_decisions_batch(decisions_data) + rollback_state = "COMPLETE" + except APIException as e: + # Rollback everything + await game_service.wipe_game_data(game_id) + await play_service.delete_plays_for_game(game_id) + await interaction.edit_original_response( + content=( + f"❌ The following errors were found in your " + f"**wk{setup_data['week']}g{setup_data['game_num']}** " + f"pitching decisions:\n\n{str(e)}\n\n" + f"Please resolve them and resubmit - thanks!" + ) + ) + return + + # Phase 12: Create Results Embed + await interaction.edit_original_response( + content="📰 Posting results..." + ) + + results_embed = await self._create_results_embed( + away_team, + home_team, + box_score, + setup_data, + current, + sheet_url, + wp, + lp, + sv, + holders, + game_id + ) + + # Phase 13: Post to News Channel + await send_to_channel( + self.bot, + SBA_NETWORK_NEWS_CHANNEL, + content=None, + embed=results_embed + ) + + # Phase 14: Recalculate Standings + await interaction.edit_original_response( + content="📊 Tallying standings..." + ) + + try: + await standings_service.recalculate_standings(current.season) + except: + # Non-critical error + self.logger.error("Failed to recalculate standings") + + # Success! + await interaction.edit_original_response( + content="✅ You are all set!" + ) + + except Exception as e: + # Unexpected error - attempt rollback + self.logger.error(f"Unexpected error in scorecard submission: {e}", exc_info=True) + + if rollback_state and game_id: + try: + if rollback_state == "GAME_PATCHED": + await game_service.wipe_game_data(game_id) + await play_service.delete_plays_for_game(game_id) + elif rollback_state == "PLAYS_POSTED": + await play_service.delete_plays_for_game(game_id) + except: + pass # Best effort rollback + + await interaction.edit_original_response( + content=f"❌ An unexpected error occurred: {str(e)}" + ) + + def _match_manager(self, team: Team, manager_name: str): + """ + Match manager name from sheet to team's manager1 or manager2. + + Args: + team: Team object + manager_name: Manager name from scorecard + + Returns: + Manager object (manager1 or manager2) + """ + if team.manager2 and team.manager2.name.lower() == manager_name.lower(): + return team.manager2 + else: + return team.manager1 + + async def _create_results_embed( + self, + away_team: Team, + home_team: Team, + box_score: dict, + setup_data: dict, + current, + sheet_url: str, + wp: Optional[Player], + lp: Optional[Player], + sv: Optional[Player], + holders: List[Player], + game_id: int + ): + """ + Create rich embed with game results. + + Args: + away_team: Away team object + home_team: Home team object + box_score: Box score data dict with 'away' and 'home' keys + setup_data: Game setup data from scorecard + current: Current league state + sheet_url: URL to scorecard + wp: Winning pitcher Player object + lp: Losing pitcher Player object + sv: Save pitcher Player object (optional) + holders: List of Player objects with holds + game_id: Game ID for key plays lookup + + Returns: + Discord Embed with game results + """ + + # Determine winner and loser + away_score = box_score['away'][0] + home_score = box_score['home'][0] + + if away_score > home_score: + winning_team = away_team + losing_team = home_team + winner_abbrev = away_team.abbrev + loser_abbrev = home_team.abbrev + winner_score = away_score + loser_score = home_score + else: + winning_team = home_team + losing_team = away_team + winner_abbrev = home_team.abbrev + loser_abbrev = away_team.abbrev + winner_score = home_score + loser_score = away_score + + # Create embed + embed = EmbedTemplate.create_base_embed( + title=f"{winner_abbrev} defeats {loser_abbrev} {winner_score}-{loser_score}", + description=f"Season {current.season}, Week {setup_data['week']}, Game {setup_data['game_num']}" + ) + embed.color = winning_team.get_color_int() + if winning_team.thumbnail: + embed.set_thumbnail(url=winning_team.thumbnail) + + # Add box score + box_score_text = ( + f"```\n" + f"{'Team':<6} {'R':<3} {'H':<3} {'E':<3}\n" + f"{away_team.abbrev:<6} {box_score['away'][0]:<3} {box_score['away'][1]:<3} {box_score['away'][2]:<3}\n" + f"{home_team.abbrev:<6} {box_score['home'][0]:<3} {box_score['home'][1]:<3} {box_score['home'][2]:<3}\n" + f"```" + ) + embed.add_field(name="Box Score", value=box_score_text, inline=False) + + # Add pitching decisions - much simpler now! + decisions_text = "" + + if wp: + decisions_text += f"**WP:** {wp.display_name}\n" + + if lp: + decisions_text += f"**LP:** {lp.display_name}\n" + + if holders: + hold_names = [holder.display_name for holder in holders] + decisions_text += f"**HLD:** {', '.join(hold_names)}\n" + + if sv: + decisions_text += f"**SV:** {sv.display_name}\n" + + if decisions_text: + embed.add_field(name="Pitching Decisions", value=decisions_text, inline=True) + + # Add scorecard link + embed.add_field( + name="Scorecard", + value=f"[View Full Scorecard]({sheet_url})", + inline=True + ) + + # Try to get key plays (non-critical) + try: + key_plays = await play_service.get_top_plays_by_wpa(game_id, limit=3) + if key_plays: + key_plays_text = format_key_plays(key_plays, away_team, home_team) + if key_plays_text: + embed.add_field(name="Key Plays", value=key_plays_text, inline=False) + except Exception as e: + self.logger.warning(f"Failed to get key plays: {e}") + + return embed + + +async def setup(bot: commands.Bot): + """Load the submit scorecard commands cog.""" + await bot.add_cog(SubmitScorecardCommands(bot)) diff --git a/config.py b/config.py index 62ee586..4ec6bef 100644 --- a/config.py +++ b/config.py @@ -25,7 +25,10 @@ class BotConfig(BaseSettings): log_level: str = "INFO" environment: str = "development" testing: bool = False - + + # Google Sheets settings + sheets_credentials_path: str = "/data/major-domo-service-creds.json" + # Optional Redis caching settings redis_url: str = "" # Empty string means no Redis caching redis_cache_ttl: int = 300 # 5 minutes default TTL diff --git a/constants.py b/constants.py index e37bd3d..78972e4 100644 --- a/constants.py +++ b/constants.py @@ -34,4 +34,14 @@ DRAFT_ROUNDS = 25 FREE_AGENT_TEAM_ID = 31 # Generic free agent team ID (same per season) # Role Names -HELP_EDITOR_ROLE_NAME = "Help Editor" # Users with this role can edit help commands \ No newline at end of file +HELP_EDITOR_ROLE_NAME = "Help Editor" # Users with this role can edit help commands +SBA_PLAYERS_ROLE_NAME = "Season 12 Players" # Current season players + +# Channel Names +SBA_NETWORK_NEWS_CHANNEL = "sba-network-news" # Channel for game results + +# Base URLs +SBA_BASE_URL = "https://sba.major-domo.app" # Base URL for web links + +# Note: Google Sheets credentials path is now managed via config.py +# Access it with: get_config().sheets_credentials_path \ No newline at end of file diff --git a/exceptions.py b/exceptions.py index bbda2be..f14bcdd 100644 --- a/exceptions.py +++ b/exceptions.py @@ -38,4 +38,9 @@ class ValidationException(BotException): class ConfigurationException(BotException): """Exception for configuration-related errors.""" + pass + + +class SheetsException(BotException): + """Exception for Google Sheets-related errors.""" pass \ No newline at end of file diff --git a/models/README.md b/models/README.md index 3ba9df6..bb644bf 100644 --- a/models/README.md +++ b/models/README.md @@ -34,6 +34,172 @@ class SBABaseModel(BaseModel): - `Player` model: `id: int = Field(..., description="Player ID from database")` - `Team` model: `id: int = Field(..., description="Team ID from database")` +### Game Submission Models (January 2025) + +New models for comprehensive game data submission from Google Sheets scorecards: + +#### Play Model (`play.py`) +Represents a single play in a baseball game with complete statistics and game state. + +**Key Features:** +- **92 total fields** supporting comprehensive play-by-play tracking +- **68 fields from scorecard**: All data read from Google Sheets Playtable +- **Required fields**: game_id, play_num, pitcher_id, on_base_code, inning details, outs, scores +- **Base running**: Tracks up to 3 runners with starting and ending positions +- **Statistics**: PA, AB, H, HR, RBI, BB, SO, SB, CS, errors, and 20+ more +- **Advanced metrics**: WPA, RE24, ballpark effects +- **Descriptive text generation**: Automatic play descriptions for key plays display + +**Field Validators:** +```python +@field_validator('on_first_final') +@classmethod +def no_final_if_no_runner_one(cls, v, info): + """Ensure on_first_final is None if no runner on first.""" + if info.data.get('on_first_id') is None: + return None + return v +``` + +**Usage Example:** +```python +play = Play( + id=1234, + game_id=567, + play_num=1, + pitcher_id=100, + batter_id=101, + on_base_code="000", + inning_half="top", + inning_num=1, + batting_order=1, + starting_outs=0, + away_score=0, + home_score=0, + homerun=1, + rbi=1, + wpa=0.15 +) + +# Generate human-readable description +description = play.descriptive_text(away_team, home_team) +# Output: "Top 1: (NYY) homers" +``` + +**Field Categories:** +- **Game Context**: game_id, play_num, inning_half, inning_num, starting_outs +- **Players**: batter_id, pitcher_id, catcher_id, defender_id, runner_id +- **Base Runners**: on_first_id, on_second_id, on_third_id (with _final positions) +- **Offensive Stats**: pa, ab, hit, rbi, double, triple, homerun, bb, so, hbp, sac +- **Defensive Stats**: outs, error, wild_pitch, passed_ball, pick_off, balk +- **Advanced**: wpa, re24_primary, re24_running, ballpark effects (bphr, bpfo, bp1b, bplo) +- **Pitching**: pitcher_rest_outs, inherited_runners, inherited_scored, on_hook_for_loss + +**API-Populated Nested Objects:** + +The Play model includes optional nested object fields for all ID references. These are populated by the API endpoint to provide complete context without additional lookups: + +```python +class Play(SBABaseModel): + # ID field with corresponding optional object + game_id: int = Field(..., description="Game ID this play belongs to") + game: Optional[Game] = Field(None, description="Game object (API-populated)") + + pitcher_id: int = Field(..., description="Pitcher ID") + pitcher: Optional[Player] = Field(None, description="Pitcher object (API-populated)") + + batter_id: Optional[int] = Field(None, description="Batter ID") + batter: Optional[Player] = Field(None, description="Batter object (API-populated)") + + # ... and so on for all player/team IDs +``` + +**Pattern Details:** +- **Placement**: Optional object field immediately follows its corresponding ID field +- **Naming**: Object field uses singular form of ID field name (e.g., `batter_id` → `batter`) +- **API Population**: Database endpoint includes nested objects in response +- **Future Enhancement**: Validators could ensure consistency between ID and object fields + +**ID Fields with Nested Objects:** +- `game_id` → `game: Optional[Game]` +- `pitcher_id` → `pitcher: Optional[Player]` +- `batter_id` → `batter: Optional[Player]` +- `batter_team_id` → `batter_team: Optional[Team]` +- `pitcher_team_id` → `pitcher_team: Optional[Team]` +- `on_first_id` → `on_first: Optional[Player]` +- `on_second_id` → `on_second: Optional[Player]` +- `on_third_id` → `on_third: Optional[Player]` +- `catcher_id` → `catcher: Optional[Player]` +- `catcher_team_id` → `catcher_team: Optional[Team]` +- `defender_id` → `defender: Optional[Player]` +- `defender_team_id` → `defender_team: Optional[Team]` +- `runner_id` → `runner: Optional[Player]` +- `runner_team_id` → `runner_team: Optional[Team]` + +**Usage Example:** +```python +# API returns play with nested objects populated +play = await play_service.get_play(play_id=123) + +# Access nested objects directly without additional lookups +if play.batter: + print(f"Batter: {play.batter.name}") +if play.pitcher: + print(f"Pitcher: {play.pitcher.name}") +if play.game: + print(f"Game: {play.game.matchup_display}") +``` + +#### Decision Model (`decision.py`) +Tracks pitching decisions (wins, losses, saves, holds) for game results. + +**Key Features:** +- **Pitching decisions**: Win, Loss, Save, Hold, Blown Save flags +- **Game metadata**: game_id, season, week, game_num +- **Pitcher workload**: rest_ip, rest_required, inherited runners +- **Human-readable repr**: Shows decision type (W/L/SV/HLD/BS) + +**Usage Example:** +```python +decision = Decision( + id=456, + game_id=567, + season=12, + week=5, + game_num=2, + pitcher_id=200, + team_id=10, + win=1, # Winning pitcher + is_start=True, + rest_ip=7.0, + rest_required=4 +) + +print(decision) +# Output: Decision(pitcher_id=200, game_id=567, type=W) +``` + +**Field Categories:** +- **Game Context**: game_id, season, week, game_num +- **Pitcher**: pitcher_id, team_id +- **Decisions**: win, loss, hold, is_save, b_save (all 0 or 1) +- **Workload**: is_start, irunners, irunners_scored, rest_ip, rest_required + +**Data Pipeline:** +``` +Google Sheets Scorecard + ↓ +SheetsService.read_playtable_data() → 68 fields per play + ↓ +PlayService.create_plays_batch() → Validate with Play model + ↓ +Database API /plays endpoint + ↓ +PlayService.get_top_plays_by_wpa() → Return Play objects + ↓ +Play.descriptive_text() → Human-readable descriptions +``` + ## Model Categories ### Core Entities @@ -53,6 +219,8 @@ class SBABaseModel(BaseModel): #### Game Operations - **`game.py`** - Individual game results and scheduling +- **`play.py`** (NEW - January 2025) - Play-by-play data for game submissions +- **`decision.py`** (NEW - January 2025) - Pitching decisions and game results - **`transaction.py`** - Player transactions (trades, waivers, etc.) #### Draft System diff --git a/models/decision.py b/models/decision.py new file mode 100644 index 0000000..26b4ca9 --- /dev/null +++ b/models/decision.py @@ -0,0 +1,57 @@ +""" +Pitching Decision Model + +Tracks wins, losses, saves, holds, and other pitching decisions for game results. +This model matches the database schema at /database/app/routers_v3/decisions.py. +""" +from pydantic import Field +from models.base import SBABaseModel + + +class Decision(SBABaseModel): + """ + Pitching decision model for game results. + + Tracks wins, losses, saves, holds, and other pitching decisions. + """ + + game_id: int = Field(..., description="Game ID") + season: int = Field(..., description="Season number") + week: int = Field(..., description="Week number") + game_num: int = Field(..., description="Game number in series") + pitcher_id: int = Field(..., description="Pitcher's player ID") + team_id: int = Field(..., description="Team ID") + + # Decision flags + win: int = Field(0, description="Win (1 or 0)") + loss: int = Field(0, description="Loss (1 or 0)") + hold: int = Field(0, description="Hold (1 or 0)") + is_save: int = Field(0, description="Save (1 or 0)") + b_save: int = Field(0, description="Blown save (1 or 0)") + + # Pitcher information + is_start: bool = Field(False, description="Was this a start?") + irunners: int = Field(0, description="Inherited runners") + irunners_scored: int = Field(0, description="Inherited runners scored") + rest_ip: float = Field(0.0, description="Rest innings pitched") + rest_required: int = Field(0, description="Rest required") + + def __repr__(self): + """String representation showing key decision info.""" + decision_type = "" + if self.win == 1: + decision_type = "W" + elif self.loss == 1: + decision_type = "L" + elif self.is_save == 1: + decision_type = "SV" + elif self.hold == 1: + decision_type = "HLD" + elif self.b_save == 1: + decision_type = "BS" + + return ( + f"Decision(pitcher_id={self.pitcher_id}, " + f"game_id={self.game_id}, " + f"type={decision_type or 'NONE'})" + ) diff --git a/models/player.py b/models/player.py index d467979..0ea9c97 100644 --- a/models/player.py +++ b/models/player.py @@ -112,6 +112,11 @@ class Player(SBABaseModel): def is_pitcher(self) -> bool: """Check if player is a pitcher.""" return self.pos_1 in ['SP', 'RP', 'P'] - + + @property + def display_name(self) -> str: + """Return the player's display name (same as name).""" + return self.name + def __str__(self): return f"{self.name} ({self.primary_position})" \ No newline at end of file diff --git a/models/team.py b/models/team.py index 1a952f8..3f18f86 100644 --- a/models/team.py +++ b/models/team.py @@ -7,6 +7,7 @@ from typing import Optional from enum import Enum from pydantic import Field +from config import get_config from models.base import SBABaseModel from models.division import Division from models.manager import Manager @@ -212,5 +213,13 @@ class Team(SBABaseModel): return f'{mgr_count} GM{"s" if mgr_count > 1 else ""}' return 'Unknown' + def get_color_int(self, default_color: Optional[str] = None) -> int: + if self.color is not None: + return int(self.color, 16) + if default_color is not None: + return int(default_color, 16) + config = get_config() + return int(config.sba_color, 16) + def __str__(self): return f"{self.abbrev} - {self.lname}" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5afdbdd..8aa8a2a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,4 @@ black>=23.0.0 ruff>=0.1.0 # Optional Dependencies -# pygsheets>=4.0.0 # For Google Sheets integration if needed \ No newline at end of file +pygsheets>=4.0.0 # For Google Sheets integration (scorecard submission) \ No newline at end of file diff --git a/services/README.md b/services/README.md index cfe5492..7858d49 100644 --- a/services/README.md +++ b/services/README.md @@ -82,8 +82,84 @@ This naming inconsistency was fixed in `services/trade_builder.py` line 201 and - **`transaction_service.py`** - Player transaction operations (trades, waivers, etc.) - **`transaction_builder.py`** - Complex transaction building and validation +### Game Submission Services (NEW - January 2025) +- **`game_service.py`** - Game CRUD operations and scorecard submission support +- **`play_service.py`** - Play-by-play data management for game submissions +- **`decision_service.py`** - Pitching decision operations for game results +- **`sheets_service.py`** - Google Sheets integration for scorecard reading + +#### GameService Key Methods +```python +class GameService(BaseService[Game]): + async def find_duplicate_game(season: int, week: int, game_num: int, + away_team_id: int, home_team_id: int) -> Optional[Game] + async def find_scheduled_game(season: int, week: int, + away_team_id: int, home_team_id: int) -> Optional[Game] + async def wipe_game_data(game_id: int) -> bool # Transaction rollback support + async def update_game_result(game_id: int, away_score: int, home_score: int, + away_manager_id: int, home_manager_id: int, + game_num: int, scorecard_url: str) -> Game +``` + +#### PlayService Key Methods +```python +class PlayService: + async def create_plays_batch(plays: List[Dict[str, Any]]) -> bool + async def delete_plays_for_game(game_id: int) -> bool # Transaction rollback + async def get_top_plays_by_wpa(game_id: int, limit: int = 3) -> List[Play] +``` + +#### DecisionService Key Methods +```python +class DecisionService: + async def create_decisions_batch(decisions: List[Dict[str, Any]]) -> bool + async def delete_decisions_for_game(game_id: int) -> bool # Transaction rollback + def find_winning_losing_pitchers(decisions_data: List[Dict[str, Any]]) + -> Tuple[Optional[int], Optional[int], Optional[int], List[int], List[int]] +``` + +#### SheetsService Key Methods +```python +class SheetsService: + async def open_scorecard(sheet_url: str) -> pygsheets.Spreadsheet + async def read_setup_data(scorecard: pygsheets.Spreadsheet) -> Dict[str, Any] + async def read_playtable_data(scorecard: pygsheets.Spreadsheet) -> List[Dict[str, Any]] + async def read_pitching_decisions(scorecard: pygsheets.Spreadsheet) -> List[Dict[str, Any]] + async def read_box_score(scorecard: pygsheets.Spreadsheet) -> Dict[str, List[int]] +``` + +**Transaction Rollback Pattern:** +The game submission services implement a 3-state transaction rollback pattern: +1. **PLAYS_POSTED**: Plays submitted → Rollback: Delete plays +2. **GAME_PATCHED**: Game updated → Rollback: Wipe game + Delete plays +3. **COMPLETE**: All data committed → No rollback needed + +**Usage Example:** +```python +# Create plays (state: PLAYS_POSTED) +await play_service.create_plays_batch(plays_data) +rollback_state = "PLAYS_POSTED" + +try: + # Update game (state: GAME_PATCHED) + await game_service.update_game_result(game_id, ...) + rollback_state = "GAME_PATCHED" + + # Create decisions (state: COMPLETE) + await decision_service.create_decisions_batch(decisions_data) + rollback_state = "COMPLETE" +except APIException as e: + # Rollback based on current state + if rollback_state == "GAME_PATCHED": + await game_service.wipe_game_data(game_id) + await play_service.delete_plays_for_game(game_id) + elif rollback_state == "PLAYS_POSTED": + await play_service.delete_plays_for_game(game_id) +``` + ### Custom Features - **`custom_commands_service.py`** - User-created custom Discord commands +- **`help_commands_service.py`** - Admin-managed help system and documentation ## Caching Integration diff --git a/services/custom_commands_service.py b/services/custom_commands_service.py index a830d20..c07afcb 100644 --- a/services/custom_commands_service.py +++ b/services/custom_commands_service.py @@ -585,7 +585,7 @@ class CustomCommandsService(BaseService[CustomCommand]): BotException: If creator not found """ creators = await self.get_items_from_table_with_params( - 'custom_command_creators', + 'custom_commands/creators', [('id', creator_id)] ) diff --git a/services/decision_service.py b/services/decision_service.py new file mode 100644 index 0000000..cf6a2cc --- /dev/null +++ b/services/decision_service.py @@ -0,0 +1,167 @@ +""" +Decision Service + +Manages pitching decision operations for game submission. +""" +from typing import List, Dict, Any, Optional, Tuple + +from utils.logging import get_contextual_logger +from api.client import get_global_client +from models.decision import Decision +from models.player import Player +from exceptions import APIException + + +class DecisionService: + """Pitching decision management service.""" + + def __init__(self): + """Initialize decision service.""" + self.logger = get_contextual_logger(f'{__name__}.DecisionService') + self._get_client = get_global_client + + async def get_client(self): + """Get the API client.""" + return await self._get_client() + + async def create_decisions_batch( + self, + decisions: List[Dict[str, Any]] + ) -> bool: + """ + POST batch of decisions to /decisions endpoint. + + Args: + decisions: List of decision dictionaries + + Returns: + True if successful + + Raises: + APIException: If POST fails + """ + try: + client = await self.get_client() + + payload = {'decisions': decisions} + await client.post('decisions', payload) + + self.logger.info(f"Created {len(decisions)} decisions") + return True + + except Exception as e: + self.logger.error(f"Failed to create decisions batch: {e}") + error_msg = self._parse_api_error(e) + raise APIException(error_msg) from e + + async def delete_decisions_for_game(self, game_id: int) -> bool: + """ + Delete all decisions for a specific game. + + Calls DELETE /decisions/game/{game_id} + + Args: + game_id: Game ID to delete decisions for + + Returns: + True if successful + + Raises: + APIException: If deletion fails + """ + try: + client = await self.get_client() + await client.delete(f'decisions/game/{game_id}') + + self.logger.info(f"Deleted decisions for game {game_id}") + return True + + except Exception as e: + self.logger.error(f"Failed to delete decisions for game {game_id}: {e}") + raise APIException(f"Failed to delete decisions: {e}") + + async def find_winning_losing_pitchers( + self, + decisions_data: List[Dict[str, Any]] + ) -> Tuple[Optional[Player], Optional[Player], Optional[Player], List[Player], List[Player]]: + """ + Extract WP, LP, SV, Holds, Blown Saves from decisions list and fetch Player objects. + + Args: + decisions_data: List of decision dictionaries from scorecard + + Returns: + Tuple of (wp, lp, sv, holders, blown_saves) + wp: Winning pitcher Player object (or None) + lp: Losing pitcher Player object (or None) + sv: Save pitcher Player object (or None) + holders: List of Player objects with holds + blown_saves: List of Player objects with blown saves + + Raises: + APIException: If any player lookup fails + """ + from services.player_service import player_service + + wp_id = None + lp_id = None + sv_id = None + hold_ids = [] + bsv_ids = [] + + # First pass: Extract IDs + for decision in decisions_data: + pitcher_id = int(decision.get('pitcher_id', 0)) + + if int(decision.get('win', 0)) == 1: + wp_id = pitcher_id + if int(decision.get('loss', 0)) == 1: + lp_id = pitcher_id + if int(decision.get('is_save', 0)) == 1: + sv_id = pitcher_id + if int(decision.get('hold', 0)) == 1: + hold_ids.append(pitcher_id) + if int(decision.get('b_save', 0)) == 1: + bsv_ids.append(pitcher_id) + + # Second pass: Fetch Player objects + wp = await player_service.get_player(wp_id) if wp_id else None + lp = await player_service.get_player(lp_id) if lp_id else None + sv = await player_service.get_player(sv_id) if sv_id else None + + holders = [] + for hold_id in hold_ids: + holder = await player_service.get_player(hold_id) + if holder: + holders.append(holder) + + blown_saves = [] + for bsv_id in bsv_ids: + bsv = await player_service.get_player(bsv_id) + if bsv: + blown_saves.append(bsv) + + return wp, lp, sv, holders, blown_saves + + def _parse_api_error(self, error: Exception) -> str: + """ + Parse API error into user-friendly message. + + Args: + error: Exception from API call + + Returns: + User-friendly error message + """ + error_str = str(error) + + if 'Player ID' in error_str and 'not found' in error_str: + return "Invalid pitcher ID in decision data." + elif 'Game ID' in error_str and 'not found' in error_str: + return "Game not found for decisions." + else: + return f"Error submitting decisions: {error_str}" + + +# Global service instance +decision_service = DecisionService() diff --git a/services/game_service.py b/services/game_service.py new file mode 100644 index 0000000..471a9dd --- /dev/null +++ b/services/game_service.py @@ -0,0 +1,182 @@ +""" +Game Service + +Manages game CRUD operations and game-specific workflows for scorecard submission. +""" +from typing import Optional + +from services.base_service import BaseService +from models.game import Game +from utils.logging import get_contextual_logger +from exceptions import APIException + + +class GameService(BaseService[Game]): + """Game management service with specialized game operations.""" + + def __init__(self): + """Initialize game service.""" + super().__init__(Game, 'games') + self.logger = get_contextual_logger(f'{__name__}.GameService') + + async def find_duplicate_game( + self, + season: int, + week: int, + game_num: int, + away_team_id: int, + home_team_id: int + ) -> Optional[Game]: + """ + Check for already-played duplicate game. + + Args: + season: Season number + week: Week number + game_num: Game number in series + away_team_id: Away team ID + home_team_id: Home team ID + + Returns: + Game if duplicate found (game_num is set), None otherwise + """ + params = [ + ('season', str(season)), + ('week', str(week)), + ('game_num', str(game_num)), + ('away_team_id', str(away_team_id)), + ('home_team_id', str(home_team_id)) + ] + + games, count = await self.get_all(params=params) + + if count > 0: + self.logger.warning( + f"Found duplicate game: S{season} W{week} G{game_num} " + f"({away_team_id} @ {home_team_id})" + ) + return games[0] + + return None + + async def find_scheduled_game( + self, + season: int, + week: int, + away_team_id: int, + home_team_id: int + ) -> Optional[Game]: + """ + Find unplayed scheduled game matching teams and week. + + Args: + season: Season number + week: Week number + away_team_id: Away team ID + home_team_id: Home team ID + + Returns: + Game if found and not yet played (game_num is None), None otherwise + """ + params = [ + ('season', str(season)), + ('week', str(week)), + ('away_team_id', str(away_team_id)), + ('home_team_id', str(home_team_id)), + ('played', 'false') # Only unplayed games + ] + + games, count = await self.get_all(params=params) + + if count == 0: + self.logger.warning( + f"No scheduled game found for S{season} W{week} " + f"({away_team_id} @ {home_team_id})" + ) + return None + + return games[0] + + async def wipe_game_data(self, game_id: int) -> bool: + """ + Wipe game scores and manager assignments. + + Calls POST /games/wipe/{game_id} which sets: + - away_score = None + - home_score = None + - game_num = None + - away_manager = None + - home_manager = None + + Args: + game_id: Game ID to wipe + + Returns: + True if successful + + Raises: + APIException: If wipe fails + """ + try: + client = await self.get_client() + response = await client.post(f'games/wipe/{game_id}', {}) + + self.logger.info(f"Wiped game {game_id}") + return True + + except Exception as e: + self.logger.error(f"Failed to wipe game {game_id}: {e}") + raise APIException(f"Failed to wipe game data: {e}") + + async def update_game_result( + self, + game_id: int, + away_score: int, + home_score: int, + away_manager_id: int, + home_manager_id: int, + game_num: int, + scorecard_url: str + ) -> Game: + """ + Update game with scores, managers, and scorecard URL. + + Args: + game_id: Game ID to update + away_score: Away team final score + home_score: Home team final score + away_manager_id: Away team manager ID + home_manager_id: Home team manager ID + game_num: Game number in series + scorecard_url: URL to scorecard + + Returns: + Updated game object + + Raises: + APIException: If update fails + """ + update_data = { + 'away_score': away_score, + 'home_score': home_score, + 'away_manager_id': away_manager_id, + 'home_manager_id': home_manager_id, + 'game_num': game_num, + 'scorecard_url': scorecard_url + } + + updated_game = await self.patch( + game_id, + update_data, + use_query_params=True # API expects query params for PATCH + ) + + if updated_game is None: + raise APIException(f"Game {game_id} not found for update") + + self.logger.info(f"Updated game {game_id} with final score") + return updated_game + + +# Global service instance +game_service = GameService() diff --git a/services/play_service.py b/services/play_service.py new file mode 100644 index 0000000..7b08bf6 --- /dev/null +++ b/services/play_service.py @@ -0,0 +1,143 @@ +""" +Play Service + +Manages play-by-play data operations for game submission. +""" +from typing import List, Dict, Any + +from utils.logging import get_contextual_logger +from api.client import get_global_client +from models.play import Play +from exceptions import APIException + + +class PlayService: + """Play-by-play data management service.""" + + def __init__(self): + """Initialize play service.""" + self.logger = get_contextual_logger(f'{__name__}.PlayService') + self._get_client = get_global_client + + async def get_client(self): + """Get the API client.""" + return await self._get_client() + + async def create_plays_batch(self, plays: List[Dict[str, Any]]) -> bool: + """ + POST batch of plays to /plays endpoint. + + Args: + plays: List of play dictionaries with game_id and play data + + Returns: + True if successful + + Raises: + APIException: If POST fails with validation errors + """ + try: + client = await self.get_client() + + payload = {'plays': plays} + response = await client.post('plays', payload) + + self.logger.info(f"Created {len(plays)} plays") + return True + + except Exception as e: + self.logger.error(f"Failed to create plays batch: {e}") + # Parse API error for user-friendly message + error_msg = self._parse_api_error(e) + raise APIException(error_msg) from e + + async def delete_plays_for_game(self, game_id: int) -> bool: + """ + Delete all plays for a specific game. + + Calls DELETE /plays/game/{game_id} + + Args: + game_id: Game ID to delete plays for + + Returns: + True if successful + + Raises: + APIException: If deletion fails + """ + try: + client = await self.get_client() + response = await client.delete(f'plays/game/{game_id}') + + self.logger.info(f"Deleted plays for game {game_id}") + return True + + except Exception as e: + self.logger.error(f"Failed to delete plays for game {game_id}: {e}") + raise APIException(f"Failed to delete plays: {e}") + + async def get_top_plays_by_wpa( + self, + game_id: int, + limit: int = 3 + ) -> List[Play]: + """ + Get top plays by WPA (absolute value) for key plays display. + + Args: + game_id: Game ID to get plays for + limit: Number of plays to return (default 3) + + Returns: + List of Play objects sorted by |WPA| descending + """ + try: + client = await self.get_client() + + params = [ + ('game_id', game_id), + ('sort', 'wpa-desc'), + ('limit', limit) + ] + + response = await client.get('plays', params=params) + + if not response or 'plays' not in response: + self.logger.info(f'No plays found for game ID {game_id}') + return [] + + plays = [Play.from_api_data(p) for p in response['plays']] + + self.logger.debug(f"Retrieved {len(plays)} top plays for game {game_id}") + return plays + + except Exception as e: + self.logger.error(f"Failed to get top plays: {e}") + return [] # Non-critical, return empty list + + def _parse_api_error(self, error: Exception) -> str: + """ + Parse API error into user-friendly message. + + Args: + error: Exception from API call + + Returns: + User-friendly error message + """ + error_str = str(error) + + # Common error patterns + if 'Player ID' in error_str and 'not found' in error_str: + return "Invalid player ID in scorecard data. Please check player IDs." + elif 'Game ID' in error_str and 'not found' in error_str: + return "Game not found in database. Please contact an admin." + elif 'validation' in error_str.lower(): + return f"Data validation error: {error_str}" + else: + return f"Error submitting plays: {error_str}" + + +# Global service instance +play_service = PlayService() diff --git a/services/sheets_service.py b/services/sheets_service.py new file mode 100644 index 0000000..8104718 --- /dev/null +++ b/services/sheets_service.py @@ -0,0 +1,315 @@ +""" +Google Sheets Service + +Handles reading data from Google Sheets scorecards for game submission. +""" +import asyncio +from typing import Dict, List, Any, Optional +import pygsheets + +from utils.logging import get_contextual_logger +from exceptions import SheetsException + + +class SheetsService: + """Google Sheets integration for scorecard reading.""" + + def __init__(self, credentials_path: Optional[str] = None): + """ + Initialize sheets service. + + Args: + credentials_path: Path to service account credentials JSON + If None, will use path from config + """ + if credentials_path is None: + from config import get_config + credentials_path = get_config().sheets_credentials_path + + self.credentials_path = credentials_path + self.logger = get_contextual_logger(f'{__name__}.SheetsService') + self._sheets_client = None + + def _get_client(self) -> pygsheets.client.Client: + """Get or create pygsheets client.""" + if self._sheets_client is None: + self._sheets_client = pygsheets.authorize( + service_file=self.credentials_path + ) + return self._sheets_client + + async def open_scorecard(self, sheet_url: str) -> pygsheets.Spreadsheet: + """ + Open and validate access to a Google Sheet. + + Args: + sheet_url: Full URL to Google Sheet + + Returns: + Opened spreadsheet object + + Raises: + SheetsException: If sheet cannot be accessed + """ + try: + # Run in thread pool since pygsheets is synchronous + loop = asyncio.get_event_loop() + sheets = await loop.run_in_executor( + None, + self._get_client + ) + scorecard = await loop.run_in_executor( + None, + sheets.open_by_url, + sheet_url + ) + + self.logger.info(f"Opened scorecard: {scorecard.title}") + return scorecard + + except Exception as e: + self.logger.error(f"Failed to open scorecard {sheet_url}: {e}") + raise SheetsException( + "Unable to access scorecard. Is it publicly readable?" + ) from e + + async def read_setup_data( + self, + scorecard: pygsheets.Spreadsheet + ) -> Dict[str, Any]: + """ + Read game metadata from Setup tab. + + Cell mappings: + - V35: Scorecard version + - C3:D7: Game data (week, game_num, teams, managers) + + Returns: + Dictionary with keys: + - version: str + - week: int + - game_num: int + - away_team_abbrev: str + - home_team_abbrev: str + - away_manager_name: str + - home_manager_name: str + """ + try: + loop = asyncio.get_event_loop() + + # Get Setup tab + setup_tab = await loop.run_in_executor( + None, + scorecard.worksheet_by_title, + 'Setup' + ) + + # Read version + version = await loop.run_in_executor( + None, + setup_tab.get_value, + 'V35' + ) + + # Read game data (C3:D7) + g_data = await loop.run_in_executor( + None, + setup_tab.get_values, + 'C3', + 'D7' + ) + + return { + 'version': version, + 'week': int(g_data[1][0]), + 'game_num': int(g_data[2][0]), + 'away_team_abbrev': g_data[3][0], + 'home_team_abbrev': g_data[4][0], + 'away_manager_name': g_data[3][1], + 'home_manager_name': g_data[4][1] + } + + except Exception as e: + self.logger.error(f"Failed to read setup data: {e}") + raise SheetsException("Unable to read game setup data") from e + + async def read_playtable_data( + self, + scorecard: pygsheets.Spreadsheet + ) -> List[Dict[str, Any]]: + """ + Read all plays from Playtable tab. + + Reads range B3:BW300 which contains up to 297 rows of play data + with 68 columns per row. + + Returns: + List of play dictionaries with field names mapped + """ + try: + loop = asyncio.get_event_loop() + + # Get Playtable tab + playtable = await loop.run_in_executor( + None, + scorecard.worksheet_by_title, + 'Playtable' + ) + + # Read play data + all_plays = await loop.run_in_executor( + None, + playtable.get_values, + 'B3', + 'BW300' + ) + + # Field names in order (from old bot lines 1621-1632) + play_keys = [ + 'play_num', 'batter_id', 'batter_pos', 'pitcher_id', + 'on_base_code', 'inning_half', 'inning_num', 'batting_order', + 'starting_outs', 'away_score', 'home_score', 'on_first_id', + 'on_first_final', 'on_second_id', 'on_second_final', + 'on_third_id', 'on_third_final', 'batter_final', 'pa', 'ab', + 'run', 'e_run', 'hit', 'rbi', 'double', 'triple', 'homerun', + 'bb', 'so', 'hbp', 'sac', 'ibb', 'gidp', 'bphr', 'bpfo', + 'bp1b', 'bplo', 'sb', 'cs', 'outs', 'pitcher_rest_outs', + 'wpa', 'catcher_id', 'defender_id', 'runner_id', 'check_pos', + 'error', 'wild_pitch', 'passed_ball', 'pick_off', 'balk', + 'is_go_ahead', 'is_tied', 'is_new_inning', 'inherited_runners', + 'inherited_scored', 'on_hook_for_loss', 'run_differential', + 'unused-manager', 'unused-pitcherpow', 'unused-pitcherrestip', + 'unused-runners', 'unused-fatigue', 'unused-roundedip', + 'unused-elitestart', 'unused-scenario', 'unused-winxaway', + 'unused-winxhome', 'unused-pinchrunner', 'unused-order', + 'hand_batting', 'hand_pitching', 're24_primary', 're24_running' + ] + + p_data = [] + for line in all_plays: + this_data = {} + for count, value in enumerate(line): + if value != '' and count < len(play_keys): + this_data[play_keys[count]] = value + + # Only include rows with meaningful data (>5 fields) + if len(this_data.keys()) > 5: + p_data.append(this_data) + + self.logger.info(f"Read {len(p_data)} plays from scorecard") + return p_data + + except Exception as e: + self.logger.error(f"Failed to read playtable data: {e}") + raise SheetsException("Unable to read play-by-play data") from e + + async def read_pitching_decisions( + self, + scorecard: pygsheets.Spreadsheet + ) -> List[Dict[str, Any]]: + """ + Read pitching decisions from Pitcherstats tab. + + Reads range B3:O30 which contains up to 27 rows of pitcher data + with 14 columns per row. + + Returns: + List of decision dictionaries with field names mapped + """ + try: + loop = asyncio.get_event_loop() + + # Get Pitcherstats tab + pitching = await loop.run_in_executor( + None, + scorecard.worksheet_by_title, + 'Pitcherstats' + ) + + # Read decision data + all_decisions = await loop.run_in_executor( + None, + pitching.get_values, + 'B3', + 'O30' + ) + + # Field names in order (from old bot lines 1688-1691) + pit_keys = [ + 'pitcher_id', 'rest_ip', 'is_start', 'base_rest', + 'extra_rest', 'rest_required', 'win', 'loss', 'is_save', + 'hold', 'b_save', 'irunners', 'irunners_scored', 'team_id' + ] + + pit_data = [] + for line in all_decisions: + if not line: # Skip empty rows + continue + + this_data = {} + for count, value in enumerate(line): + if value != '' and count < len(pit_keys): + this_data[pit_keys[count]] = value + + if this_data: # Only include non-empty rows + pit_data.append(this_data) + + self.logger.info(f"Read {len(pit_data)} pitching decisions") + return pit_data + + except Exception as e: + self.logger.error(f"Failed to read pitching decisions: {e}") + raise SheetsException("Unable to read pitching decisions") from e + + async def read_box_score( + self, + scorecard: pygsheets.Spreadsheet + ) -> Dict[str, List[int]]: + """ + Read box score from Scorecard or Box Score tab. + + Tries 'Scorecard' tab first (BW8:BY9), falls back to + 'Box Score' tab (T6:V7). + + Returns: + Dictionary with 'away' and 'home' keys, each containing + [runs, hits, errors] + """ + try: + loop = asyncio.get_event_loop() + + # Try Scorecard tab first + try: + sc_tab = await loop.run_in_executor( + None, + scorecard.worksheet_by_title, + 'Scorecard' + ) + score_table = await loop.run_in_executor( + None, + sc_tab.get_values, + 'BW8', + 'BY9' + ) + except pygsheets.WorksheetNotFound: + # Fallback to Box Score tab + sc_tab = await loop.run_in_executor( + None, + scorecard.worksheet_by_title, + 'Box Score' + ) + score_table = await loop.run_in_executor( + None, + sc_tab.get_values, + 'T6', + 'V7' + ) + + return { + 'away': [int(x) for x in score_table[0]], # [R, H, E] + 'home': [int(x) for x in score_table[1]] # [R, H, E] + } + + except Exception as e: + self.logger.error(f"Failed to read box score: {e}") + raise SheetsException("Unable to read box score") from e diff --git a/services/standings_service.py b/services/standings_service.py index a3fa77a..1d71bf2 100644 --- a/services/standings_service.py +++ b/services/standings_service.py @@ -198,6 +198,38 @@ class StandingsService: logger.error(f"Error generating playoff picture: {e}") return {"division_leaders": [], "wild_card": []} + async def recalculate_standings(self, season: int) -> bool: + """ + Trigger standings recalculation for a season. + + Calls POST /standings/s{season}/recalculate + + Args: + season: Season number to recalculate + + Returns: + True if successful + + Raises: + APIException: If recalculation fails + """ + try: + client = await self.get_client() + + # Use 8 second timeout for this potentially slow operation + response = await client.post( + f'standings/s{season}/recalculate', + {}, + timeout=8.0 + ) + + logger.info(f"Recalculated standings for season {season}") + return True + + except Exception as e: + logger.error(f"Failed to recalculate standings: {e}") + raise APIException(f"Failed to recalculate standings: {e}") + # Global service instance standings_service = StandingsService() \ No newline at end of file diff --git a/utils/README.md b/utils/README.md index 4bbcd52..c925ea7 100644 --- a/utils/README.md +++ b/utils/README.md @@ -561,15 +561,152 @@ See [Redis Caching](#-redis-caching) section above for caching decorator documen --- +## 🚀 Discord Helpers + +**Location:** `utils/discord_helpers.py` (NEW - January 2025) +**Purpose:** Common Discord-related helper functions for channel lookups, message sending, and formatting. + +### **Available Functions** + +#### **`get_channel_by_name(bot, channel_name)`** +Get a text channel by name from the configured guild: + +```python +from utils.discord_helpers import get_channel_by_name + +# In your command or cog +channel = await get_channel_by_name(self.bot, "sba-network-news") +if channel: + await channel.send("Message content") +``` + +**Features:** +- Retrieves guild ID from environment (`GUILD_ID`) +- Returns `TextChannel` object or `None` if not found +- Handles errors gracefully with logging +- Works across all guilds the bot is in + +#### **`send_to_channel(bot, channel_name, content=None, embed=None)`** +Send a message to a channel by name: + +```python +from utils.discord_helpers import send_to_channel + +# Send text message +success = await send_to_channel( + self.bot, + "sba-network-news", + content="Game results posted!" +) + +# Send embed +success = await send_to_channel( + self.bot, + "sba-network-news", + embed=results_embed +) + +# Send both +success = await send_to_channel( + self.bot, + "sba-network-news", + content="Check out these results:", + embed=results_embed +) +``` + +**Features:** +- Combined channel lookup and message sending +- Supports text content, embeds, or both +- Returns `True` on success, `False` on failure +- Comprehensive error logging +- Non-critical - doesn't raise exceptions + +#### **`format_key_plays(plays, away_team, home_team)`** +Format top plays into embed field text for game results: + +```python +from utils.discord_helpers import format_key_plays +from services.play_service import play_service + +# Get top 3 plays by WPA +top_plays = await play_service.get_top_plays_by_wpa(game_id, limit=3) + +# Format for display +key_plays_text = format_key_plays(top_plays, away_team, home_team) + +# Add to embed +if key_plays_text: + embed.add_field(name="Key Plays", value=key_plays_text, inline=False) +``` + +**Output Example:** +``` +Top 3: (NYY) homers in 2 runs, NYY up 3-1 +Bot 5: (BOS) doubles scoring 1 run, tied at 3 +Top 9: (NYY) singles scoring 1 run, NYY up 4-3 +``` + +**Features:** +- Uses `Play.descriptive_text()` for human-readable descriptions +- Adds score context after each play +- Shows which team is leading or if tied +- Returns empty string if no plays provided +- Handles RBI adjustments for accurate score display + +### **Real-World Usage** + +#### **Scorecard Submission Results Posting** +From `commands/league/submit_scorecard.py`: + +```python +# Create results embed +results_embed = await self._create_results_embed( + away_team, home_team, box_score, setup_data, + current, sheet_url, wp_id, lp_id, sv_id, hold_ids, game_id +) + +# Post to news channel automatically +await send_to_channel( + self.bot, + SBA_NETWORK_NEWS_CHANNEL, # "sba-network-news" + content=None, + embed=results_embed +) +``` + +### **Configuration** + +These functions rely on environment variables: +- **`GUILD_ID`**: Discord server ID where channels should be found +- **`SBA_NETWORK_NEWS_CHANNEL`**: Channel name for game results (constant) + +### **Error Handling** + +All functions handle errors gracefully: +- **Channel not found**: Logs warning and returns `None` or `False` +- **Missing GUILD_ID**: Logs error and returns `None` or `False` +- **Send failures**: Logs error with details and returns `False` +- **Empty data**: Returns empty string or `False` without errors + +### **Testing Considerations** + +When testing commands that use these utilities: +- Mock `get_channel_by_name()` to return test channel objects +- Mock `send_to_channel()` to verify message content +- Mock `format_key_plays()` to verify play formatting logic +- Use test guild IDs in environment variables + +--- + ## 🚀 Future Utilities Additional utility modules planned for future implementation: -### **Discord Helpers** (Planned) -- Embed builders and formatters +### **Permission Utilities** (Planned) - Permission checking decorators -- User mention and role utilities -- Message pagination helpers +- Role validation helpers +- User authorization utilities ### **API Utilities** (Planned) - Rate limiting decorators diff --git a/views/README.md b/views/README.md index 206a514..4c37aab 100644 --- a/views/README.md +++ b/views/README.md @@ -42,16 +42,93 @@ class BaseView(discord.ui.View): """Handle view errors with user feedback.""" ``` -#### ConfirmationView Class -Standard Yes/No confirmation dialogs: +#### ConfirmationView Class (Updated January 2025) +Reusable confirmation dialog with Confirm/Cancel buttons (`confirmations.py`): +**Key Features:** +- **User restriction**: Only specified users can interact +- **Customizable labels and styles**: Flexible button appearance +- **Timeout handling**: Automatic cleanup after timeout +- **Three-state result**: `True` (confirmed), `False` (cancelled), `None` (timeout) +- **Clean interface**: Automatically removes buttons after interaction + +**Usage Pattern:** ```python -confirmation = ConfirmationView( - user_id=interaction.user.id, - confirm_callback=handle_confirm, - cancel_callback=handle_cancel +from views.confirmations import ConfirmationView + +# Create confirmation dialog +view = ConfirmationView( + responders=[interaction.user], # Only this user can interact + timeout=30.0, # 30 second timeout + confirm_label="Yes, delete", # Custom label + cancel_label="No, keep it" # Custom label +) + +# Send confirmation +await interaction.edit_original_response( + content="⚠️ Are you sure you want to delete this?", + view=view +) + +# Wait for user response +await view.wait() + +# Check result +if view.confirmed is True: + # User clicked Confirm + await interaction.edit_original_response( + content="✅ Deleted successfully", + view=None + ) +elif view.confirmed is False: + # User clicked Cancel + await interaction.edit_original_response( + content="❌ Cancelled", + view=None + ) +else: + # Timeout occurred (view.confirmed is None) + await interaction.edit_original_response( + content="⏱️ Request timed out", + view=None + ) +``` + +**Real-World Example (Scorecard Submission):** +```python +# From commands/league/submit_scorecard.py +if duplicate_game: + view = ConfirmationView( + responders=[interaction.user], + timeout=30.0 + ) + await interaction.edit_original_response( + content=( + f"⚠️ This game has already been played!\n" + f"Would you like me to wipe the old one and re-submit?" + ), + view=view + ) + await view.wait() + + if view.confirmed: + # User confirmed - proceed with wipe and resubmit + await wipe_old_data() + else: + # User cancelled - exit gracefully + return +``` + +**Configuration Options:** +```python +ConfirmationView( + responders=[user1, user2], # Multiple users allowed + timeout=60.0, # Custom timeout + confirm_label="Approve", # Custom confirm text + cancel_label="Reject", # Custom cancel text + confirm_style=discord.ButtonStyle.red, # Custom button style + cancel_style=discord.ButtonStyle.grey # Custom button style ) -await interaction.followup.send("Confirm action?", view=confirmation) ``` #### PaginationView Class diff --git a/views/confirmations.py b/views/confirmations.py new file mode 100644 index 0000000..cf09f24 --- /dev/null +++ b/views/confirmations.py @@ -0,0 +1,111 @@ +""" +Confirmation Views + +Reusable confirmation dialogs for user interactions. +""" +import discord +from typing import List, Optional, Union + + +class ConfirmationView(discord.ui.View): + """ + Reusable confirmation dialog with Confirm/Cancel buttons. + + Usage: + view = ConfirmationView(responders=[interaction.user]) + await interaction.edit_original_response( + content="Are you sure?", + view=view + ) + await view.wait() + + if view.confirmed: + # User clicked Confirm + elif view.confirmed is False: + # User clicked Cancel + else: + # Timeout (view.confirmed is None) + + Attributes: + confirmed: True if confirmed, False if cancelled, None if timeout + """ + + def __init__( + self, + responders: List[Union[discord.User, discord.Member]], + timeout: float = 30.0, + confirm_label: str = "Confirm", + cancel_label: str = "Cancel", + confirm_style: discord.ButtonStyle = discord.ButtonStyle.green, + cancel_style: discord.ButtonStyle = discord.ButtonStyle.grey + ): + """ + Initialize confirmation view. + + Args: + responders: List of users/members who can interact with this view + timeout: Timeout in seconds (default 30) + confirm_label: Label for confirm button + cancel_label: Label for cancel button + confirm_style: Button style for confirm + cancel_style: Button style for cancel + """ + super().__init__(timeout=timeout) + + if not isinstance(responders, list): + raise TypeError('responders must be a list of discord.User or discord.Member objects') + + self.confirmed: Optional[bool] = None + self.responders: List[Union[discord.User, discord.Member]] = responders + + # Create buttons with custom labels and styles + self.confirm_button.label = confirm_label + self.confirm_button.style = confirm_style + self.cancel_button.label = cancel_label + self.cancel_button.style = cancel_style + + @discord.ui.button(label='Confirm', style=discord.ButtonStyle.green) + async def confirm_button( + self, + interaction: discord.Interaction, + button: discord.ui.Button + ): + """Handle confirm button click.""" + if interaction.user not in self.responders: + await interaction.response.send_message( + "❌ You cannot interact with this confirmation.", + ephemeral=True + ) + return + + self.confirmed = True + self.clear_items() + self.stop() + + # Defer to prevent "interaction failed" message + await interaction.response.defer() + + @discord.ui.button(label='Cancel', style=discord.ButtonStyle.grey) + async def cancel_button( + self, + interaction: discord.Interaction, + button: discord.ui.Button + ): + """Handle cancel button click.""" + if interaction.user not in self.responders: + await interaction.response.send_message( + "❌ You cannot interact with this confirmation.", + ephemeral=True + ) + return + + self.confirmed = False + self.clear_items() + self.stop() + + # Defer to prevent "interaction failed" message + await interaction.response.defer() + + async def on_timeout(self): + """Handle timeout - confirmed remains None.""" + self.clear_items()