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>`
|
### `/charts <chart_name>`
|
||||||
Display a gameplay chart or infographic
|
Display a gameplay chart or infographic
|
||||||
- **Parameters:**
|
- **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
|
- Recent/upcoming game overview
|
||||||
- Game completion tracking
|
- 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
|
## Architecture Notes
|
||||||
|
|
||||||
### Decorator Usage
|
### Decorator Usage
|
||||||
@ -76,9 +131,21 @@ All commands use the `@logged_command` decorator pattern:
|
|||||||
- `services.league_service`
|
- `services.league_service`
|
||||||
- `services.standings_service`
|
- `services.standings_service`
|
||||||
- `services.schedule_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.decorators.logged_command`
|
||||||
|
- `utils.discord_helpers` (NEW) - Channel and message utilities
|
||||||
|
- `utils.team_utils`
|
||||||
- `views.embeds.EmbedTemplate`
|
- `views.embeds.EmbedTemplate`
|
||||||
|
- `views.confirmations.ConfirmationView` (NEW) - Reusable confirmation dialog
|
||||||
- `constants.SBA_CURRENT_SEASON`
|
- `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
|
### Testing
|
||||||
Run tests with: `python -m pytest tests/test_commands_league.py -v`
|
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 .info import LeagueInfoCommands
|
||||||
from .standings import StandingsCommands
|
from .standings import StandingsCommands
|
||||||
from .schedule import ScheduleCommands
|
from .schedule import ScheduleCommands
|
||||||
|
from .submit_scorecard import SubmitScorecardCommands
|
||||||
|
|
||||||
logger = logging.getLogger(f'{__name__}.setup_league')
|
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),
|
("LeagueInfoCommands", LeagueInfoCommands),
|
||||||
("StandingsCommands", StandingsCommands),
|
("StandingsCommands", StandingsCommands),
|
||||||
("ScheduleCommands", ScheduleCommands),
|
("ScheduleCommands", ScheduleCommands),
|
||||||
|
("SubmitScorecardCommands", SubmitScorecardCommands),
|
||||||
]
|
]
|
||||||
|
|
||||||
successful = 0
|
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"
|
environment: str = "development"
|
||||||
testing: bool = False
|
testing: bool = False
|
||||||
|
|
||||||
|
# Google Sheets settings
|
||||||
|
sheets_credentials_path: str = "/data/major-domo-service-creds.json"
|
||||||
|
|
||||||
# Optional Redis caching settings
|
# Optional Redis caching settings
|
||||||
redis_url: str = "" # Empty string means no Redis caching
|
redis_url: str = "" # Empty string means no Redis caching
|
||||||
redis_cache_ttl: int = 300 # 5 minutes default TTL
|
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
|
# Role Names
|
||||||
HELP_EDITOR_ROLE_NAME = "Help Editor" # Users with this role can edit help commands
|
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):
|
class ConfigurationException(BotException):
|
||||||
"""Exception for configuration-related errors."""
|
"""Exception for configuration-related errors."""
|
||||||
pass
|
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")`
|
- `Player` model: `id: int = Field(..., description="Player ID from database")`
|
||||||
- `Team` model: `id: int = Field(..., description="Team 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
|
## Model Categories
|
||||||
|
|
||||||
### Core Entities
|
### Core Entities
|
||||||
@ -53,6 +219,8 @@ class SBABaseModel(BaseModel):
|
|||||||
|
|
||||||
#### Game Operations
|
#### Game Operations
|
||||||
- **`game.py`** - Individual game results and scheduling
|
- **`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.)
|
- **`transaction.py`** - Player transactions (trades, waivers, etc.)
|
||||||
|
|
||||||
#### Draft System
|
#### 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."""
|
"""Check if player is a pitcher."""
|
||||||
return self.pos_1 in ['SP', 'RP', 'P']
|
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):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.primary_position})"
|
return f"{self.name} ({self.primary_position})"
|
||||||
@ -7,6 +7,7 @@ from typing import Optional
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
|
from config import get_config
|
||||||
from models.base import SBABaseModel
|
from models.base import SBABaseModel
|
||||||
from models.division import Division
|
from models.division import Division
|
||||||
from models.manager import Manager
|
from models.manager import Manager
|
||||||
@ -212,5 +213,13 @@ class Team(SBABaseModel):
|
|||||||
return f'{mgr_count} GM{"s" if mgr_count > 1 else ""}'
|
return f'{mgr_count} GM{"s" if mgr_count > 1 else ""}'
|
||||||
return 'Unknown'
|
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):
|
def __str__(self):
|
||||||
return f"{self.abbrev} - {self.lname}"
|
return f"{self.abbrev} - {self.lname}"
|
||||||
@ -17,4 +17,4 @@ black>=23.0.0
|
|||||||
ruff>=0.1.0
|
ruff>=0.1.0
|
||||||
|
|
||||||
# Optional Dependencies
|
# 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_service.py`** - Player transaction operations (trades, waivers, etc.)
|
||||||
- **`transaction_builder.py`** - Complex transaction building and validation
|
- **`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 Features
|
||||||
- **`custom_commands_service.py`** - User-created custom Discord commands
|
- **`custom_commands_service.py`** - User-created custom Discord commands
|
||||||
|
- **`help_commands_service.py`** - Admin-managed help system and documentation
|
||||||
|
|
||||||
## Caching Integration
|
## Caching Integration
|
||||||
|
|
||||||
|
|||||||
@ -585,7 +585,7 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
|||||||
BotException: If creator not found
|
BotException: If creator not found
|
||||||
"""
|
"""
|
||||||
creators = await self.get_items_from_table_with_params(
|
creators = await self.get_items_from_table_with_params(
|
||||||
'custom_command_creators',
|
'custom_commands/creators',
|
||||||
[('id', creator_id)]
|
[('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}")
|
logger.error(f"Error generating playoff picture: {e}")
|
||||||
return {"division_leaders": [], "wild_card": []}
|
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
|
# Global service instance
|
||||||
standings_service = StandingsService()
|
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
|
## 🚀 Future Utilities
|
||||||
|
|
||||||
Additional utility modules planned for future implementation:
|
Additional utility modules planned for future implementation:
|
||||||
|
|
||||||
### **Discord Helpers** (Planned)
|
### **Permission Utilities** (Planned)
|
||||||
- Embed builders and formatters
|
|
||||||
- Permission checking decorators
|
- Permission checking decorators
|
||||||
- User mention and role utilities
|
- Role validation helpers
|
||||||
- Message pagination helpers
|
- User authorization utilities
|
||||||
|
|
||||||
### **API Utilities** (Planned)
|
### **API Utilities** (Planned)
|
||||||
- Rate limiting decorators
|
- Rate limiting decorators
|
||||||
|
|||||||
@ -42,16 +42,93 @@ class BaseView(discord.ui.View):
|
|||||||
"""Handle view errors with user feedback."""
|
"""Handle view errors with user feedback."""
|
||||||
```
|
```
|
||||||
|
|
||||||
#### ConfirmationView Class
|
#### ConfirmationView Class (Updated January 2025)
|
||||||
Standard Yes/No confirmation dialogs:
|
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
|
```python
|
||||||
confirmation = ConfirmationView(
|
from views.confirmations import ConfirmationView
|
||||||
user_id=interaction.user.id,
|
|
||||||
confirm_callback=handle_confirm,
|
# Create confirmation dialog
|
||||||
cancel_callback=handle_cancel
|
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
|
#### 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