CLAUDE: Add comprehensive scorecard submission system

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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-16 00:21:32 -05:00
parent 998e09d0c0
commit 2409c27c1d
22 changed files with 2111 additions and 17 deletions

View File

@ -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`

View File

@ -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

View File

@ -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))

View File

@ -26,6 +26,9 @@ class BotConfig(BaseSettings):
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

View File

@ -35,3 +35,13 @@ 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
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

View File

@ -39,3 +39,8 @@ class ValidationException(BotException):
class ConfigurationException(BotException):
"""Exception for configuration-related errors."""
pass
class SheetsException(BotException):
"""Exception for Google Sheets-related errors."""
pass

View File

@ -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

57
models/decision.py Normal file
View File

@ -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'})"
)

View File

@ -113,5 +113,10 @@ class Player(SBABaseModel):
"""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})"

View File

@ -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}"

View File

@ -17,4 +17,4 @@ black>=23.0.0
ruff>=0.1.0
# Optional Dependencies
# pygsheets>=4.0.0 # For Google Sheets integration if needed
pygsheets>=4.0.0 # For Google Sheets integration (scorecard submission)

View File

@ -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

View File

@ -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)]
)

View File

@ -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()

182
services/game_service.py Normal file
View File

@ -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()

143
services/play_service.py Normal file
View File

@ -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()

315
services/sheets_service.py Normal file
View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

111
views/confirmations.py Normal file
View File

@ -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()