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:
parent
998e09d0c0
commit
2409c27c1d
@ -135,7 +135,7 @@ Roll ballpark weather for a team
|
||||
### `/charts <chart_name>`
|
||||
Display a gameplay chart or infographic
|
||||
- **Parameters:**
|
||||
- `chart_name`: Name of chart (autocomplete enabled)
|
||||
- `chart_nam e`: Name of chart (autocomplete enabled)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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`
|
||||
@ -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
|
||||
|
||||
528
commands/league/submit_scorecard.py
Normal file
528
commands/league/submit_scorecard.py
Normal 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))
|
||||
@ -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
|
||||
|
||||
10
constants.py
10
constants.py
@ -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
|
||||
@ -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
|
||||
168
models/README.md
168
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
|
||||
|
||||
57
models/decision.py
Normal file
57
models/decision.py
Normal 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'})"
|
||||
)
|
||||
@ -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})"
|
||||
@ -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}"
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)]
|
||||
)
|
||||
|
||||
|
||||
167
services/decision_service.py
Normal file
167
services/decision_service.py
Normal 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
182
services/game_service.py
Normal 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
143
services/play_service.py
Normal 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
315
services/sheets_service.py
Normal 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
|
||||
@ -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()
|
||||
145
utils/README.md
145
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
|
||||
|
||||
@ -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
111
views/confirmations.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user