Merge pull request 'fix: ContextualLogger crashes when callers pass exc_info=True' (#14) from fix/contextual-logger-exc-info into main
All checks were successful
Build Docker Image / build (push) Successful in 48s
All checks were successful
Build Docker Image / build (push) Successful in 48s
Reviewed-on: #14
This commit is contained in:
commit
eaaa9496a3
@ -4,6 +4,7 @@ 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
|
||||
@ -36,24 +37,18 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.logger = get_contextual_logger(f'{__name__}.SubmitScorecardCommands')
|
||||
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"
|
||||
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(get_config().sba_players_role_name)
|
||||
@logged_command("/submit-scorecard")
|
||||
async def submit_scorecard(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
sheet_url: str
|
||||
):
|
||||
async def submit_scorecard(self, interaction: discord.Interaction, sheet_url: str):
|
||||
"""
|
||||
Submit scorecard with full transaction rollback support.
|
||||
|
||||
@ -97,7 +92,7 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
setup_data = await self.sheets_service.read_setup_data(scorecard)
|
||||
|
||||
# Validate scorecard version
|
||||
if setup_data['version'] != current.bet_week:
|
||||
if setup_data["version"] != current.bet_week:
|
||||
await interaction.edit_original_response(
|
||||
content=(
|
||||
f"❌ This scorecard appears out of date (version {setup_data['version']}, "
|
||||
@ -113,12 +108,10 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
)
|
||||
|
||||
away_team = await team_service.get_team_by_abbrev(
|
||||
setup_data['away_team_abbrev'],
|
||||
current.season
|
||||
setup_data["away_team_abbrev"], current.season
|
||||
)
|
||||
home_team = await team_service.get_team_by_abbrev(
|
||||
setup_data['home_team_abbrev'],
|
||||
current.season
|
||||
setup_data["home_team_abbrev"], current.season
|
||||
)
|
||||
|
||||
if not away_team or not home_team:
|
||||
@ -129,18 +122,15 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
|
||||
# Match managers
|
||||
away_manager = self._match_manager(
|
||||
away_team,
|
||||
setup_data['away_manager_name']
|
||||
away_team, setup_data["away_manager_name"]
|
||||
)
|
||||
home_manager = self._match_manager(
|
||||
home_team,
|
||||
setup_data['home_manager_name']
|
||||
home_team, setup_data["home_manager_name"]
|
||||
)
|
||||
|
||||
# Phase 3: Permission Check
|
||||
user_team = await get_user_major_league_team(
|
||||
interaction.user.id,
|
||||
current.season
|
||||
interaction.user.id, current.season
|
||||
)
|
||||
|
||||
if user_team is None:
|
||||
@ -160,30 +150,26 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
# Phase 4: Duplicate Game Check
|
||||
duplicate_game = await game_service.find_duplicate_game(
|
||||
current.season,
|
||||
setup_data['week'],
|
||||
setup_data['game_num'],
|
||||
setup_data["week"],
|
||||
setup_data["game_num"],
|
||||
away_team.id,
|
||||
home_team.id
|
||||
home_team.id,
|
||||
)
|
||||
|
||||
if duplicate_game:
|
||||
view = ConfirmationView(
|
||||
responders=[interaction.user],
|
||||
timeout=30.0
|
||||
)
|
||||
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
|
||||
view=view,
|
||||
)
|
||||
await view.wait()
|
||||
|
||||
if view.confirmed:
|
||||
await interaction.edit_original_response(
|
||||
content="🗑️ Wiping old game data...",
|
||||
view=None
|
||||
content="🗑️ Wiping old game data...", view=None
|
||||
)
|
||||
|
||||
# Delete old data
|
||||
@ -193,7 +179,9 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
pass # May not exist
|
||||
|
||||
try:
|
||||
await decision_service.delete_decisions_for_game(duplicate_game.id)
|
||||
await decision_service.delete_decisions_for_game(
|
||||
duplicate_game.id
|
||||
)
|
||||
except:
|
||||
pass # May not exist
|
||||
|
||||
@ -202,16 +190,13 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
else:
|
||||
await interaction.edit_original_response(
|
||||
content="❌ You think on it some more and get back to me later.",
|
||||
view=None
|
||||
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
|
||||
current.season, setup_data["week"], away_team.id, home_team.id
|
||||
)
|
||||
|
||||
if not scheduled_game:
|
||||
@ -234,7 +219,7 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
|
||||
# Add game_id to each play
|
||||
for play in plays_data:
|
||||
play['game_id'] = game_id
|
||||
play["game_id"] = game_id
|
||||
|
||||
# Phase 7: POST Plays
|
||||
await interaction.edit_original_response(
|
||||
@ -244,7 +229,9 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
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]}')
|
||||
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(
|
||||
@ -269,14 +256,16 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
if not DRY_RUN:
|
||||
await game_service.update_game_result(
|
||||
game_id,
|
||||
box_score['away'][0], # Runs
|
||||
box_score['home'][0], # Runs
|
||||
box_score["away"][0], # Runs
|
||||
box_score["home"][0], # Runs
|
||||
away_manager.id,
|
||||
home_manager.id,
|
||||
setup_data['game_num'],
|
||||
sheet_url
|
||||
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}')
|
||||
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
|
||||
@ -287,18 +276,21 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
return
|
||||
|
||||
# Phase 10: Read Pitching Decisions
|
||||
decisions_data = await self.sheets_service.read_pitching_decisions(scorecard)
|
||||
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']
|
||||
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 = \
|
||||
wp, lp, sv, holders, _blown_saves = (
|
||||
await decision_service.find_winning_losing_pitchers(decisions_data)
|
||||
)
|
||||
|
||||
if wp is None or lp is None:
|
||||
# Rollback
|
||||
@ -333,9 +325,7 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
return
|
||||
|
||||
# Phase 12: Create Results Embed
|
||||
await interaction.edit_original_response(
|
||||
content="📰 Posting results..."
|
||||
)
|
||||
await interaction.edit_original_response(content="📰 Posting results...")
|
||||
|
||||
results_embed = await self._create_results_embed(
|
||||
away_team,
|
||||
@ -348,7 +338,7 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
lp,
|
||||
sv,
|
||||
holders,
|
||||
game_id
|
||||
game_id,
|
||||
)
|
||||
|
||||
# Phase 13: Post to News Channel
|
||||
@ -356,13 +346,11 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
self.bot,
|
||||
get_config().sba_network_news_channel,
|
||||
content=None,
|
||||
embed=results_embed
|
||||
embed=results_embed,
|
||||
)
|
||||
|
||||
# Phase 14: Recalculate Standings
|
||||
await interaction.edit_original_response(
|
||||
content="📊 Tallying standings..."
|
||||
)
|
||||
await interaction.edit_original_response(content="📊 Tallying standings...")
|
||||
|
||||
try:
|
||||
await standings_service.recalculate_standings(current.season)
|
||||
@ -371,13 +359,11 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
self.logger.error("Failed to recalculate standings")
|
||||
|
||||
# Success!
|
||||
await interaction.edit_original_response(
|
||||
content="✅ You are all set!"
|
||||
)
|
||||
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)
|
||||
self.logger.error(f"Unexpected error in scorecard submission: {e}", error=e)
|
||||
|
||||
if rollback_state and game_id:
|
||||
try:
|
||||
@ -421,7 +407,7 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
lp: Optional[Player],
|
||||
sv: Optional[Player],
|
||||
holders: List[Player],
|
||||
game_id: int
|
||||
game_id: int,
|
||||
):
|
||||
"""
|
||||
Create rich embed with game results.
|
||||
@ -444,8 +430,8 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
"""
|
||||
|
||||
# Determine winner and loser
|
||||
away_score = box_score['away'][0]
|
||||
home_score = box_score['home'][0]
|
||||
away_score = box_score["away"][0]
|
||||
home_score = box_score["home"][0]
|
||||
|
||||
if away_score > home_score:
|
||||
winning_team = away_team
|
||||
@ -465,7 +451,7 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
# 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']}"
|
||||
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:
|
||||
@ -498,13 +484,13 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
decisions_text += f"**SV:** {sv.display_name}\n"
|
||||
|
||||
if decisions_text:
|
||||
embed.add_field(name="Pitching Decisions", value=decisions_text, inline=True)
|
||||
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
|
||||
name="Scorecard", value=f"[View Full Scorecard]({sheet_url})", inline=True
|
||||
)
|
||||
|
||||
# Try to get key plays (non-critical)
|
||||
@ -513,7 +499,9 @@ class SubmitScorecardCommands(commands.Cog):
|
||||
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)
|
||||
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}")
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ Tests for Weather Command (Discord interactions)
|
||||
|
||||
Validates weather command functionality, team resolution, and embed creation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import discord
|
||||
@ -53,54 +54,93 @@ class TestWeatherCommands:
|
||||
"""Create mock team data."""
|
||||
return TeamFactory.create(
|
||||
id=499,
|
||||
abbrev='NYY',
|
||||
sname='Yankees',
|
||||
lname='New York Yankees',
|
||||
abbrev="NYY",
|
||||
sname="Yankees",
|
||||
lname="New York Yankees",
|
||||
season=13,
|
||||
color='a6ce39',
|
||||
stadium='https://example.com/yankee-stadium.jpg',
|
||||
thumbnail='https://example.com/yankee-thumbnail.png'
|
||||
color="a6ce39",
|
||||
stadium="https://example.com/yankee-stadium.jpg",
|
||||
thumbnail="https://example.com/yankee-thumbnail.png",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_current(self):
|
||||
"""Create mock current league state."""
|
||||
return CurrentFactory.create(
|
||||
week=10,
|
||||
season=13,
|
||||
freeze=False,
|
||||
trade_deadline=14,
|
||||
playoffs_begin=19
|
||||
week=10, season=13, freeze=False, trade_deadline=14, playoffs_begin=19
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_games(self):
|
||||
"""Create mock game schedule."""
|
||||
# Create teams for the games
|
||||
yankees = TeamFactory.create(id=499, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=13)
|
||||
red_sox = TeamFactory.create(id=500, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=13)
|
||||
yankees = TeamFactory.create(
|
||||
id=499, abbrev="NYY", sname="Yankees", lname="New York Yankees", season=13
|
||||
)
|
||||
red_sox = TeamFactory.create(
|
||||
id=500, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=13
|
||||
)
|
||||
|
||||
# 2 completed games, 2 upcoming games
|
||||
games = [
|
||||
GameFactory.completed(id=1, season=13, week=10, game_num=1, away_team=yankees, home_team=red_sox, away_score=5, home_score=3),
|
||||
GameFactory.completed(id=2, season=13, week=10, game_num=2, away_team=yankees, home_team=red_sox, away_score=2, home_score=7),
|
||||
GameFactory.upcoming(id=3, season=13, week=10, game_num=3, away_team=yankees, home_team=red_sox),
|
||||
GameFactory.upcoming(id=4, season=13, week=10, game_num=4, away_team=yankees, home_team=red_sox),
|
||||
GameFactory.completed(
|
||||
id=1,
|
||||
season=13,
|
||||
week=10,
|
||||
game_num=1,
|
||||
away_team=yankees,
|
||||
home_team=red_sox,
|
||||
away_score=5,
|
||||
home_score=3,
|
||||
),
|
||||
GameFactory.completed(
|
||||
id=2,
|
||||
season=13,
|
||||
week=10,
|
||||
game_num=2,
|
||||
away_team=yankees,
|
||||
home_team=red_sox,
|
||||
away_score=2,
|
||||
home_score=7,
|
||||
),
|
||||
GameFactory.upcoming(
|
||||
id=3,
|
||||
season=13,
|
||||
week=10,
|
||||
game_num=3,
|
||||
away_team=yankees,
|
||||
home_team=red_sox,
|
||||
),
|
||||
GameFactory.upcoming(
|
||||
id=4,
|
||||
season=13,
|
||||
week=10,
|
||||
game_num=4,
|
||||
away_team=yankees,
|
||||
home_team=red_sox,
|
||||
),
|
||||
]
|
||||
return games
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weather_explicit_team(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games):
|
||||
async def test_weather_explicit_team(
|
||||
self, commands_cog, mock_interaction, mock_team, mock_current, mock_games
|
||||
):
|
||||
"""Test weather command with explicit team abbreviation."""
|
||||
with patch('utils.permissions.get_user_team') as mock_get_user_team, \
|
||||
patch('commands.utilities.weather.league_service') as mock_league_service, \
|
||||
patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \
|
||||
patch('commands.utilities.weather.team_service') as mock_team_service:
|
||||
with patch("utils.permissions.get_user_team") as mock_get_user_team, patch(
|
||||
"commands.utilities.weather.league_service"
|
||||
) as mock_league_service, patch(
|
||||
"commands.utilities.weather.schedule_service"
|
||||
) as mock_schedule_service, patch(
|
||||
"commands.utilities.weather.team_service"
|
||||
) as mock_team_service:
|
||||
|
||||
# Mock @requires_team decorator lookup
|
||||
mock_get_user_team.return_value = {
|
||||
'id': mock_team.id, 'name': mock_team.lname,
|
||||
'abbrev': mock_team.abbrev, 'season': mock_team.season
|
||||
"id": mock_team.id,
|
||||
"name": mock_team.lname,
|
||||
"abbrev": mock_team.abbrev,
|
||||
"season": mock_team.season,
|
||||
}
|
||||
|
||||
# Mock service responses
|
||||
@ -109,37 +149,47 @@ class TestWeatherCommands:
|
||||
mock_team_service.get_team_by_abbrev = AsyncMock(return_value=mock_team)
|
||||
|
||||
# Execute command
|
||||
await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev='NYY')
|
||||
await commands_cog.weather.callback(
|
||||
commands_cog, mock_interaction, team_abbrev="NYY"
|
||||
)
|
||||
|
||||
# Verify interaction flow
|
||||
mock_interaction.response.defer.assert_called_once()
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
|
||||
# Verify team lookup
|
||||
mock_team_service.get_team_by_abbrev.assert_called_once_with('NYY', 13)
|
||||
mock_team_service.get_team_by_abbrev.assert_called_once_with("NYY", 13)
|
||||
|
||||
# Check embed was sent
|
||||
embed_call = mock_interaction.followup.send.call_args
|
||||
assert 'embed' in embed_call.kwargs
|
||||
embed = embed_call.kwargs['embed']
|
||||
assert "embed" in embed_call.kwargs
|
||||
embed = embed_call.kwargs["embed"]
|
||||
assert embed.title == "🌤️ Weather Check"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weather_channel_name_resolution(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games):
|
||||
async def test_weather_channel_name_resolution(
|
||||
self, commands_cog, mock_interaction, mock_team, mock_current, mock_games
|
||||
):
|
||||
"""Test weather command resolving team from channel name."""
|
||||
# Set channel name to format: <abbrev>-<park name>
|
||||
mock_interaction.channel.name = "NYY-Yankee-Stadium"
|
||||
|
||||
with patch('utils.permissions.get_user_team') as mock_get_user_team, \
|
||||
patch('commands.utilities.weather.league_service') as mock_league_service, \
|
||||
patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \
|
||||
patch('commands.utilities.weather.team_service') as mock_team_service, \
|
||||
patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team:
|
||||
with patch("utils.permissions.get_user_team") as mock_get_user_team, patch(
|
||||
"commands.utilities.weather.league_service"
|
||||
) as mock_league_service, patch(
|
||||
"commands.utilities.weather.schedule_service"
|
||||
) as mock_schedule_service, patch(
|
||||
"commands.utilities.weather.team_service"
|
||||
) as mock_team_service, patch(
|
||||
"commands.utilities.weather.get_user_major_league_team"
|
||||
) as mock_get_team:
|
||||
|
||||
# Mock @requires_team decorator lookup
|
||||
mock_get_user_team.return_value = {
|
||||
'id': mock_team.id, 'name': mock_team.lname,
|
||||
'abbrev': mock_team.abbrev, 'season': mock_team.season
|
||||
"id": mock_team.id,
|
||||
"name": mock_team.lname,
|
||||
"abbrev": mock_team.abbrev,
|
||||
"season": mock_team.season,
|
||||
}
|
||||
|
||||
mock_league_service.get_current_state = AsyncMock(return_value=mock_current)
|
||||
@ -148,28 +198,38 @@ class TestWeatherCommands:
|
||||
mock_get_team.return_value = None
|
||||
|
||||
# Execute without explicit team parameter
|
||||
await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev=None)
|
||||
await commands_cog.weather.callback(
|
||||
commands_cog, mock_interaction, team_abbrev=None
|
||||
)
|
||||
|
||||
# Should resolve team from channel name "NYY-Yankee-Stadium" -> "NYY"
|
||||
mock_team_service.get_team_by_abbrev.assert_called_once_with('NYY', 13)
|
||||
mock_team_service.get_team_by_abbrev.assert_called_once_with("NYY", 13)
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weather_user_owned_team_fallback(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games):
|
||||
async def test_weather_user_owned_team_fallback(
|
||||
self, commands_cog, mock_interaction, mock_team, mock_current, mock_games
|
||||
):
|
||||
"""Test weather command falling back to user's owned team."""
|
||||
# Set channel name that won't match a team
|
||||
mock_interaction.channel.name = "general-chat"
|
||||
|
||||
with patch('utils.permissions.get_user_team') as mock_get_user_team, \
|
||||
patch('commands.utilities.weather.league_service') as mock_league_service, \
|
||||
patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \
|
||||
patch('commands.utilities.weather.team_service') as mock_team_service, \
|
||||
patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team:
|
||||
with patch("utils.permissions.get_user_team") as mock_get_user_team, patch(
|
||||
"commands.utilities.weather.league_service"
|
||||
) as mock_league_service, patch(
|
||||
"commands.utilities.weather.schedule_service"
|
||||
) as mock_schedule_service, patch(
|
||||
"commands.utilities.weather.team_service"
|
||||
) as mock_team_service, patch(
|
||||
"commands.utilities.weather.get_user_major_league_team"
|
||||
) as mock_get_team:
|
||||
|
||||
# Mock @requires_team decorator lookup
|
||||
mock_get_user_team.return_value = {
|
||||
'id': mock_team.id, 'name': mock_team.lname,
|
||||
'abbrev': mock_team.abbrev, 'season': mock_team.season
|
||||
"id": mock_team.id,
|
||||
"name": mock_team.lname,
|
||||
"abbrev": mock_team.abbrev,
|
||||
"season": mock_team.season,
|
||||
}
|
||||
|
||||
mock_league_service.get_current_state = AsyncMock(return_value=mock_current)
|
||||
@ -177,48 +237,64 @@ class TestWeatherCommands:
|
||||
mock_team_service.get_team_by_abbrev = AsyncMock(return_value=None)
|
||||
mock_get_team.return_value = mock_team
|
||||
|
||||
await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev=None)
|
||||
await commands_cog.weather.callback(
|
||||
commands_cog, mock_interaction, team_abbrev=None
|
||||
)
|
||||
|
||||
# Should fall back to user ownership
|
||||
mock_get_team.assert_called_once_with(258104532423147520, 13)
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weather_no_team_found(self, commands_cog, mock_interaction, mock_current, mock_team):
|
||||
async def test_weather_no_team_found(
|
||||
self, commands_cog, mock_interaction, mock_current, mock_team
|
||||
):
|
||||
"""Test weather command when no team can be resolved."""
|
||||
with patch('utils.permissions.get_user_team') as mock_get_user_team, \
|
||||
patch('commands.utilities.weather.league_service') as mock_league_service, \
|
||||
patch('commands.utilities.weather.team_service') as mock_team_service, \
|
||||
patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team:
|
||||
with patch("utils.permissions.get_user_team") as mock_get_user_team, patch(
|
||||
"commands.utilities.weather.league_service"
|
||||
) as mock_league_service, patch(
|
||||
"commands.utilities.weather.team_service"
|
||||
) as mock_team_service, patch(
|
||||
"commands.utilities.weather.get_user_major_league_team"
|
||||
) as mock_get_team:
|
||||
|
||||
# Mock @requires_team decorator lookup - user has a team so decorator passes
|
||||
mock_get_user_team.return_value = {
|
||||
'id': mock_team.id, 'name': mock_team.lname,
|
||||
'abbrev': mock_team.abbrev, 'season': mock_team.season
|
||||
"id": mock_team.id,
|
||||
"name": mock_team.lname,
|
||||
"abbrev": mock_team.abbrev,
|
||||
"season": mock_team.season,
|
||||
}
|
||||
|
||||
mock_league_service.get_current_state = AsyncMock(return_value=mock_current)
|
||||
mock_team_service.get_team_by_abbrev = AsyncMock(return_value=None)
|
||||
mock_get_team.return_value = None
|
||||
|
||||
await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev=None)
|
||||
await commands_cog.weather.callback(
|
||||
commands_cog, mock_interaction, team_abbrev=None
|
||||
)
|
||||
|
||||
# Should send error message
|
||||
embed_call = mock_interaction.followup.send.call_args
|
||||
embed = embed_call.kwargs['embed']
|
||||
embed = embed_call.kwargs["embed"]
|
||||
assert "Team Not Found" in embed.title
|
||||
assert "Could not find a team" in embed.description
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weather_league_state_unavailable(self, commands_cog, mock_interaction, mock_team):
|
||||
async def test_weather_league_state_unavailable(
|
||||
self, commands_cog, mock_interaction, mock_team
|
||||
):
|
||||
"""Test weather command when league state is unavailable."""
|
||||
with patch('utils.permissions.get_user_team') as mock_get_user_team, \
|
||||
patch('commands.utilities.weather.league_service') as mock_league_service:
|
||||
with patch("utils.permissions.get_user_team") as mock_get_user_team, patch(
|
||||
"commands.utilities.weather.league_service"
|
||||
) as mock_league_service:
|
||||
|
||||
# Mock @requires_team decorator lookup
|
||||
mock_get_user_team.return_value = {
|
||||
'id': mock_team.id, 'name': mock_team.lname,
|
||||
'abbrev': mock_team.abbrev, 'season': mock_team.season
|
||||
"id": mock_team.id,
|
||||
"name": mock_team.lname,
|
||||
"abbrev": mock_team.abbrev,
|
||||
"season": mock_team.season,
|
||||
}
|
||||
|
||||
mock_league_service.get_current_state = AsyncMock(return_value=None)
|
||||
@ -227,7 +303,7 @@ class TestWeatherCommands:
|
||||
|
||||
# Should send error about league state
|
||||
embed_call = mock_interaction.followup.send.call_args
|
||||
embed = embed_call.kwargs['embed']
|
||||
embed = embed_call.kwargs["embed"]
|
||||
assert "League State Unavailable" in embed.title
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -329,7 +405,7 @@ class TestWeatherCommands:
|
||||
weather_roll=14,
|
||||
games_played=2,
|
||||
total_games=4,
|
||||
username="TestUser"
|
||||
username="TestUser",
|
||||
)
|
||||
|
||||
# Check embed basics
|
||||
@ -363,74 +439,94 @@ class TestWeatherCommands:
|
||||
assert embed.image.url == mock_team.stadium
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_weather_workflow(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games):
|
||||
async def test_full_weather_workflow(
|
||||
self, commands_cog, mock_interaction, mock_team, mock_current, mock_games
|
||||
):
|
||||
"""Test complete weather workflow with realistic data."""
|
||||
with patch('utils.permissions.get_user_team') as mock_get_user_team, \
|
||||
patch('commands.utilities.weather.league_service') as mock_league_service, \
|
||||
patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \
|
||||
patch('commands.utilities.weather.team_service') as mock_team_service:
|
||||
with patch("utils.permissions.get_user_team") as mock_get_user_team, patch(
|
||||
"commands.utilities.weather.league_service"
|
||||
) as mock_league_service, patch(
|
||||
"commands.utilities.weather.schedule_service"
|
||||
) as mock_schedule_service, patch(
|
||||
"commands.utilities.weather.team_service"
|
||||
) as mock_team_service:
|
||||
|
||||
# Mock @requires_team decorator lookup
|
||||
mock_get_user_team.return_value = {
|
||||
'id': mock_team.id, 'name': mock_team.lname,
|
||||
'abbrev': mock_team.abbrev, 'season': mock_team.season
|
||||
"id": mock_team.id,
|
||||
"name": mock_team.lname,
|
||||
"abbrev": mock_team.abbrev,
|
||||
"season": mock_team.season,
|
||||
}
|
||||
|
||||
mock_league_service.get_current_state = AsyncMock(return_value=mock_current)
|
||||
mock_schedule_service.get_week_schedule = AsyncMock(return_value=mock_games)
|
||||
mock_team_service.get_team_by_abbrev = AsyncMock(return_value=mock_team)
|
||||
|
||||
await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev='NYY')
|
||||
await commands_cog.weather.callback(
|
||||
commands_cog, mock_interaction, team_abbrev="NYY"
|
||||
)
|
||||
|
||||
# Verify complete flow
|
||||
mock_interaction.response.defer.assert_called_once()
|
||||
mock_league_service.get_current_state.assert_called_once()
|
||||
mock_schedule_service.get_week_schedule.assert_called_once_with(13, 10)
|
||||
mock_team_service.get_team_by_abbrev.assert_called_once_with('NYY', 13)
|
||||
mock_team_service.get_team_by_abbrev.assert_called_once_with("NYY", 13)
|
||||
|
||||
# Check final embed
|
||||
embed_call = mock_interaction.followup.send.call_args
|
||||
embed = embed_call.kwargs['embed']
|
||||
embed = embed_call.kwargs["embed"]
|
||||
|
||||
# Validate embed structure
|
||||
assert "Weather Check" in embed.title
|
||||
assert len(embed.fields) == 4 # Season, Time, Week, Roll
|
||||
assert len(embed.fields) == 5 # Season, Time, Week, Roll, Stadium Image
|
||||
assert embed.image.url == mock_team.stadium
|
||||
assert embed.color.value == int(mock_team.color, 16)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_resolution_priority(self, commands_cog, mock_interaction, mock_current):
|
||||
async def test_team_resolution_priority(
|
||||
self, commands_cog, mock_interaction, mock_current
|
||||
):
|
||||
"""Test that team resolution follows correct priority order."""
|
||||
team1 = TeamFactory.create(id=1, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=12)
|
||||
team2 = TeamFactory.create(id=2, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12)
|
||||
team3 = TeamFactory.create(id=3, abbrev='LAD', sname='Dodgers', lname='Los Angeles Dodgers', season=12)
|
||||
team1 = TeamFactory.create(
|
||||
id=1, abbrev="NYY", sname="Yankees", lname="New York Yankees", season=12
|
||||
)
|
||||
team2 = TeamFactory.create(
|
||||
id=2, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12
|
||||
)
|
||||
team3 = TeamFactory.create(
|
||||
id=3, abbrev="LAD", sname="Dodgers", lname="Los Angeles Dodgers", season=12
|
||||
)
|
||||
|
||||
# Test Priority 1: Explicit parameter (should return team1)
|
||||
with patch('commands.utilities.weather.team_service') as mock_team_service:
|
||||
with patch("commands.utilities.weather.team_service") as mock_team_service:
|
||||
mock_team_service.get_team_by_abbrev = AsyncMock(return_value=team1)
|
||||
|
||||
result = await commands_cog._resolve_team(mock_interaction, 'NYY', 12)
|
||||
assert result.abbrev == 'NYY'
|
||||
result = await commands_cog._resolve_team(mock_interaction, "NYY", 12)
|
||||
assert result.abbrev == "NYY"
|
||||
assert result.id == 1
|
||||
|
||||
# Test Priority 2: Channel name (should return team2)
|
||||
mock_interaction.channel.name = "BOS-Fenway-Park"
|
||||
with patch('commands.utilities.weather.team_service') as mock_team_service:
|
||||
with patch("commands.utilities.weather.team_service") as mock_team_service:
|
||||
mock_team_service.get_team_by_abbrev = AsyncMock(return_value=team2)
|
||||
|
||||
result = await commands_cog._resolve_team(mock_interaction, None, 12)
|
||||
assert result.abbrev == 'BOS'
|
||||
assert result.abbrev == "BOS"
|
||||
assert result.id == 2
|
||||
|
||||
# Test Priority 3: User ownership (should return team3)
|
||||
mock_interaction.channel.name = "general"
|
||||
with patch('commands.utilities.weather.team_service') as mock_team_service, \
|
||||
patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team:
|
||||
with patch(
|
||||
"commands.utilities.weather.team_service"
|
||||
) as mock_team_service, patch(
|
||||
"commands.utilities.weather.get_user_major_league_team"
|
||||
) as mock_get_team:
|
||||
mock_team_service.get_team_by_abbrev = AsyncMock(return_value=None)
|
||||
mock_get_team.return_value = team3
|
||||
|
||||
result = await commands_cog._resolve_team(mock_interaction, None, 12)
|
||||
assert result.abbrev == 'LAD'
|
||||
assert result.abbrev == "LAD"
|
||||
assert result.id == 3
|
||||
|
||||
|
||||
@ -452,12 +548,25 @@ class TestWeatherCommandsIntegration:
|
||||
@pytest.fixture
|
||||
def mock_games(self):
|
||||
"""Create mock game schedule for integration tests."""
|
||||
yankees = TeamFactory.create(id=499, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=12)
|
||||
red_sox = TeamFactory.create(id=500, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12)
|
||||
yankees = TeamFactory.create(
|
||||
id=499, abbrev="NYY", sname="Yankees", lname="New York Yankees", season=12
|
||||
)
|
||||
red_sox = TeamFactory.create(
|
||||
id=500, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12
|
||||
)
|
||||
|
||||
# 1 completed game for division week testing
|
||||
games = [
|
||||
GameFactory.completed(id=1, season=12, week=10, game_num=1, away_team=yankees, home_team=red_sox, away_score=5, home_score=3)
|
||||
GameFactory.completed(
|
||||
id=1,
|
||||
season=12,
|
||||
week=10,
|
||||
game_num=1,
|
||||
away_team=yankees,
|
||||
home_team=red_sox,
|
||||
away_score=5,
|
||||
home_score=3,
|
||||
)
|
||||
]
|
||||
return games
|
||||
|
||||
@ -470,7 +579,9 @@ class TestWeatherCommandsIntegration:
|
||||
# 1 game played in division week should be Night
|
||||
one_game = [mock_games[0]]
|
||||
time_of_day = commands_cog._get_time_of_day(one_game, week)
|
||||
assert "Night" in time_of_day, f"Week {week} should be Night with 1 game in division week"
|
||||
assert (
|
||||
"Night" in time_of_day
|
||||
), f"Week {week} should be Night with 1 game in division week"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_season_transitions(self, commands_cog):
|
||||
|
||||
249
utils/logging.py
249
utils/logging.py
@ -4,6 +4,7 @@ Enhanced Logging Utilities
|
||||
Provides structured logging with contextual information for Discord bot debugging.
|
||||
Implements hybrid approach: human-readable console + structured JSON files.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
@ -13,66 +14,82 @@ from datetime import datetime
|
||||
from typing import Dict, Any, Optional, Union
|
||||
|
||||
# Context variable for request tracking across async calls
|
||||
log_context: contextvars.ContextVar[Dict[str, Any]] = contextvars.ContextVar('log_context', default={})
|
||||
log_context: contextvars.ContextVar[Dict[str, Any]] = contextvars.ContextVar(
|
||||
"log_context", default={}
|
||||
)
|
||||
|
||||
logger = logging.getLogger(f'{__name__}.logging_utils')
|
||||
logger = logging.getLogger(f"{__name__}.logging_utils")
|
||||
|
||||
JSONValue = Union[
|
||||
str,
|
||||
int,
|
||||
float,
|
||||
bool,
|
||||
None,
|
||||
dict[str, Any], # nested object
|
||||
list[Any] # arrays
|
||||
str, int, float, bool, None, dict[str, Any], list[Any] # nested object # arrays
|
||||
]
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Custom JSON formatter for structured file logging."""
|
||||
|
||||
|
||||
def format(self, record) -> str:
|
||||
"""Format log record as JSON with context information."""
|
||||
# Base log object
|
||||
log_obj: dict[str, JSONValue] = {
|
||||
'timestamp': datetime.now().isoformat() + 'Z',
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'message': record.getMessage()
|
||||
"timestamp": datetime.now().isoformat() + "Z",
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
|
||||
|
||||
# Add function/line info if available
|
||||
if hasattr(record, 'funcName') and record.funcName:
|
||||
log_obj['function'] = record.funcName
|
||||
if hasattr(record, 'lineno') and record.lineno:
|
||||
log_obj['line'] = record.lineno
|
||||
|
||||
if hasattr(record, "funcName") and record.funcName:
|
||||
log_obj["function"] = record.funcName
|
||||
if hasattr(record, "lineno") and record.lineno:
|
||||
log_obj["line"] = record.lineno
|
||||
|
||||
# Add exception info if present
|
||||
if record.exc_info:
|
||||
log_obj['exception'] = {
|
||||
'type': record.exc_info[0].__name__ if record.exc_info[0] else 'Unknown',
|
||||
'message': str(record.exc_info[1]) if record.exc_info[1] else 'No message',
|
||||
'traceback': self.formatException(record.exc_info)
|
||||
log_obj["exception"] = {
|
||||
"type": (
|
||||
record.exc_info[0].__name__ if record.exc_info[0] else "Unknown"
|
||||
),
|
||||
"message": (
|
||||
str(record.exc_info[1]) if record.exc_info[1] else "No message"
|
||||
),
|
||||
"traceback": self.formatException(record.exc_info),
|
||||
}
|
||||
|
||||
|
||||
# Add context from contextvars
|
||||
context = log_context.get({})
|
||||
if context:
|
||||
log_obj['context'] = context.copy()
|
||||
|
||||
log_obj["context"] = context.copy()
|
||||
|
||||
# Promote trace_id to standard key if available in context
|
||||
if 'trace_id' in context:
|
||||
log_obj['trace_id'] = context['trace_id']
|
||||
|
||||
if "trace_id" in context:
|
||||
log_obj["trace_id"] = context["trace_id"]
|
||||
|
||||
# Add custom fields from extra parameter
|
||||
excluded_keys = {
|
||||
'name', 'msg', 'args', 'levelname', 'levelno', 'pathname',
|
||||
'filename', 'module', 'lineno', 'funcName', 'created',
|
||||
'msecs', 'relativeCreated', 'thread', 'threadName',
|
||||
'processName', 'process', 'getMessage', 'exc_info',
|
||||
'exc_text', 'stack_info'
|
||||
"name",
|
||||
"msg",
|
||||
"args",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"pathname",
|
||||
"filename",
|
||||
"module",
|
||||
"lineno",
|
||||
"funcName",
|
||||
"created",
|
||||
"msecs",
|
||||
"relativeCreated",
|
||||
"thread",
|
||||
"threadName",
|
||||
"processName",
|
||||
"process",
|
||||
"getMessage",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"stack_info",
|
||||
}
|
||||
|
||||
|
||||
extra_data = {}
|
||||
for key, value in record.__dict__.items():
|
||||
if key not in excluded_keys:
|
||||
@ -82,56 +99,56 @@ class JSONFormatter(logging.Formatter):
|
||||
extra_data[key] = value
|
||||
except (TypeError, ValueError):
|
||||
extra_data[key] = str(value)
|
||||
|
||||
|
||||
if extra_data:
|
||||
log_obj['extra'] = extra_data
|
||||
|
||||
return json.dumps(log_obj, ensure_ascii=False) + '\n'
|
||||
log_obj["extra"] = extra_data
|
||||
|
||||
return json.dumps(log_obj, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
class ContextualLogger:
|
||||
"""
|
||||
Logger wrapper that provides contextual information and structured logging.
|
||||
|
||||
|
||||
Automatically includes Discord context (user, guild, command) in all log messages.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, logger_name: str):
|
||||
"""
|
||||
Initialize contextual logger.
|
||||
|
||||
|
||||
Args:
|
||||
logger_name: Name for the underlying logger
|
||||
"""
|
||||
self.logger = logging.getLogger(logger_name)
|
||||
self._start_time: Optional[float] = None
|
||||
|
||||
|
||||
def start_operation(self, operation_name: Optional[str] = None) -> str:
|
||||
"""
|
||||
Start timing an operation and generate a trace ID.
|
||||
|
||||
|
||||
Args:
|
||||
operation_name: Optional name for the operation being tracked
|
||||
|
||||
|
||||
Returns:
|
||||
Generated trace ID for this operation
|
||||
"""
|
||||
self._start_time = time.time()
|
||||
trace_id = str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
# Add trace_id to context
|
||||
current_context = log_context.get({})
|
||||
current_context['trace_id'] = trace_id
|
||||
current_context["trace_id"] = trace_id
|
||||
if operation_name:
|
||||
current_context['operation'] = operation_name
|
||||
current_context["operation"] = operation_name
|
||||
log_context.set(current_context)
|
||||
|
||||
|
||||
return trace_id
|
||||
|
||||
|
||||
def end_operation(self, trace_id: str, operation_result: str = "completed") -> None:
|
||||
"""
|
||||
End an operation and log the final duration.
|
||||
|
||||
|
||||
Args:
|
||||
trace_id: The trace ID returned by start_operation
|
||||
operation_result: Result status (e.g., "completed", "failed", "cancelled")
|
||||
@ -139,83 +156,99 @@ class ContextualLogger:
|
||||
if self._start_time is None:
|
||||
self.warning("end_operation called without corresponding start_operation")
|
||||
return
|
||||
|
||||
|
||||
duration_ms = int((time.time() - self._start_time) * 1000)
|
||||
|
||||
|
||||
# Get current context
|
||||
current_context = log_context.get({})
|
||||
|
||||
|
||||
# Log operation completion
|
||||
self.info(f"Operation {operation_result}",
|
||||
trace_id=trace_id,
|
||||
final_duration_ms=duration_ms,
|
||||
operation_result=operation_result)
|
||||
|
||||
self.info(
|
||||
f"Operation {operation_result}",
|
||||
trace_id=trace_id,
|
||||
final_duration_ms=duration_ms,
|
||||
operation_result=operation_result,
|
||||
)
|
||||
|
||||
# Clear operation-specific context
|
||||
if 'operation' in current_context:
|
||||
current_context.pop('operation', None)
|
||||
if 'trace_id' in current_context and current_context['trace_id'] == trace_id:
|
||||
current_context.pop('trace_id', None)
|
||||
if "operation" in current_context:
|
||||
current_context.pop("operation", None)
|
||||
if "trace_id" in current_context and current_context["trace_id"] == trace_id:
|
||||
current_context.pop("trace_id", None)
|
||||
log_context.set(current_context)
|
||||
|
||||
|
||||
# Reset start time
|
||||
self._start_time = None
|
||||
|
||||
|
||||
def _get_duration_ms(self) -> Optional[int]:
|
||||
"""Get operation duration in milliseconds if start_operation was called."""
|
||||
if self._start_time:
|
||||
return int((time.time() - self._start_time) * 1000)
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _extract_logging_params(kwargs: dict) -> dict:
|
||||
"""Extract standard logging parameters from kwargs to prevent LogRecord conflicts.
|
||||
|
||||
Python's LogRecord raises KeyError if reserved attributes like 'exc_info' or
|
||||
'stack_info' are passed via the 'extra' dict. This extracts them so they can
|
||||
be passed as direct parameters to the underlying logger instead.
|
||||
"""
|
||||
return {
|
||||
key: kwargs.pop(key) for key in ("exc_info", "stack_info") if key in kwargs
|
||||
}
|
||||
|
||||
def debug(self, message: str, **kwargs):
|
||||
"""Log debug message with context."""
|
||||
duration = self._get_duration_ms()
|
||||
if duration is not None:
|
||||
kwargs['duration_ms'] = duration
|
||||
self.logger.debug(message, extra=kwargs)
|
||||
|
||||
kwargs["duration_ms"] = duration
|
||||
log_params = self._extract_logging_params(kwargs)
|
||||
self.logger.debug(message, extra=kwargs, **log_params)
|
||||
|
||||
def info(self, message: str, **kwargs):
|
||||
"""Log info message with context."""
|
||||
duration = self._get_duration_ms()
|
||||
if duration is not None:
|
||||
kwargs['duration_ms'] = duration
|
||||
self.logger.info(message, extra=kwargs)
|
||||
|
||||
kwargs["duration_ms"] = duration
|
||||
log_params = self._extract_logging_params(kwargs)
|
||||
self.logger.info(message, extra=kwargs, **log_params)
|
||||
|
||||
def warning(self, message: str, **kwargs):
|
||||
"""Log warning message with context."""
|
||||
duration = self._get_duration_ms()
|
||||
if duration is not None:
|
||||
kwargs['duration_ms'] = duration
|
||||
self.logger.warning(message, extra=kwargs)
|
||||
|
||||
kwargs["duration_ms"] = duration
|
||||
log_params = self._extract_logging_params(kwargs)
|
||||
self.logger.warning(message, extra=kwargs, **log_params)
|
||||
|
||||
def error(self, message: str, error: Optional[Exception] = None, **kwargs):
|
||||
"""
|
||||
Log error message with context and exception information.
|
||||
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
error: Optional exception object
|
||||
**kwargs: Additional context
|
||||
**kwargs: Additional context (exc_info=True is supported)
|
||||
"""
|
||||
duration = self._get_duration_ms()
|
||||
if duration is not None:
|
||||
kwargs['duration_ms'] = duration
|
||||
|
||||
kwargs["duration_ms"] = duration
|
||||
log_params = self._extract_logging_params(kwargs)
|
||||
|
||||
if error:
|
||||
kwargs['error'] = {
|
||||
'type': type(error).__name__,
|
||||
'message': str(error)
|
||||
}
|
||||
kwargs["error"] = {"type": type(error).__name__, "message": str(error)}
|
||||
self.logger.error(message, exc_info=True, extra=kwargs)
|
||||
else:
|
||||
self.logger.error(message, extra=kwargs)
|
||||
|
||||
self.logger.error(message, extra=kwargs, **log_params)
|
||||
|
||||
def exception(self, message: str, **kwargs):
|
||||
"""Log exception with full traceback and context."""
|
||||
duration = self._get_duration_ms()
|
||||
if duration is not None:
|
||||
kwargs['duration_ms'] = duration
|
||||
self.logger.exception(message, extra=kwargs)
|
||||
kwargs["duration_ms"] = duration
|
||||
log_params = self._extract_logging_params(kwargs)
|
||||
self.logger.exception(message, extra=kwargs, **log_params)
|
||||
|
||||
|
||||
def set_discord_context(
|
||||
@ -224,45 +257,45 @@ def set_discord_context(
|
||||
guild_id: Optional[Union[str, int]] = None,
|
||||
channel_id: Optional[Union[str, int]] = None,
|
||||
command: Optional[str] = None,
|
||||
**additional_context
|
||||
**additional_context,
|
||||
):
|
||||
"""
|
||||
Set Discord-specific context for logging.
|
||||
|
||||
|
||||
Args:
|
||||
interaction: Discord interaction object (will extract user/guild/channel)
|
||||
user_id: Discord user ID
|
||||
guild_id: Discord guild ID
|
||||
guild_id: Discord guild ID
|
||||
channel_id: Discord channel ID
|
||||
command: Command name (e.g., '/player')
|
||||
**additional_context: Any additional context to include
|
||||
"""
|
||||
context = log_context.get({}).copy()
|
||||
|
||||
|
||||
# Extract from interaction if provided
|
||||
if interaction:
|
||||
context['user_id'] = str(interaction.user.id)
|
||||
context["user_id"] = str(interaction.user.id)
|
||||
if interaction.guild:
|
||||
context['guild_id'] = str(interaction.guild.id)
|
||||
context['guild_name'] = interaction.guild.name
|
||||
context["guild_id"] = str(interaction.guild.id)
|
||||
context["guild_name"] = interaction.guild.name
|
||||
if interaction.channel:
|
||||
context['channel_id'] = str(interaction.channel.id)
|
||||
if hasattr(interaction, 'command') and interaction.command:
|
||||
context['command'] = f"/{interaction.command.name}"
|
||||
|
||||
context["channel_id"] = str(interaction.channel.id)
|
||||
if hasattr(interaction, "command") and interaction.command:
|
||||
context["command"] = f"/{interaction.command.name}"
|
||||
|
||||
# Override with explicit parameters
|
||||
if user_id:
|
||||
context['user_id'] = str(user_id)
|
||||
context["user_id"] = str(user_id)
|
||||
if guild_id:
|
||||
context['guild_id'] = str(guild_id)
|
||||
context["guild_id"] = str(guild_id)
|
||||
if channel_id:
|
||||
context['channel_id'] = str(channel_id)
|
||||
context["channel_id"] = str(channel_id)
|
||||
if command:
|
||||
context['command'] = command
|
||||
|
||||
context["command"] = command
|
||||
|
||||
# Add any additional context
|
||||
context.update(additional_context)
|
||||
|
||||
|
||||
log_context.set(context)
|
||||
|
||||
|
||||
@ -274,11 +307,11 @@ def clear_context():
|
||||
def get_contextual_logger(logger_name: str) -> ContextualLogger:
|
||||
"""
|
||||
Get a contextual logger instance.
|
||||
|
||||
|
||||
Args:
|
||||
logger_name: Name for the logger (typically __name__)
|
||||
|
||||
|
||||
Returns:
|
||||
ContextualLogger instance
|
||||
"""
|
||||
return ContextualLogger(logger_name)
|
||||
return ContextualLogger(logger_name)
|
||||
|
||||
190
views/players.py
190
views/players.py
@ -3,6 +3,7 @@ Player View Components
|
||||
|
||||
Interactive Discord UI components for player information display with toggleable statistics.
|
||||
"""
|
||||
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
import discord
|
||||
@ -34,11 +35,11 @@ class PlayerStatsView(BaseView):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
player: 'Player',
|
||||
player: "Player",
|
||||
season: int,
|
||||
batting_stats: Optional['BattingStats'] = None,
|
||||
pitching_stats: Optional['PitchingStats'] = None,
|
||||
user_id: Optional[int] = None
|
||||
batting_stats: Optional["BattingStats"] = None,
|
||||
pitching_stats: Optional["PitchingStats"] = None,
|
||||
user_id: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the player stats view.
|
||||
@ -50,7 +51,9 @@ class PlayerStatsView(BaseView):
|
||||
pitching_stats: Pitching statistics (if available)
|
||||
user_id: Discord user ID who can interact with this view
|
||||
"""
|
||||
super().__init__(timeout=300.0, user_id=user_id, logger_name=f'{__name__}.PlayerStatsView')
|
||||
super().__init__(
|
||||
timeout=300.0, user_id=user_id, logger_name=f"{__name__}.PlayerStatsView"
|
||||
)
|
||||
|
||||
self.player = player
|
||||
self.season = season
|
||||
@ -69,36 +72,37 @@ class PlayerStatsView(BaseView):
|
||||
self.remove_item(self.toggle_pitching_button)
|
||||
self.logger.debug("No pitching stats available, pitching button hidden")
|
||||
|
||||
self.logger.info("PlayerStatsView initialized",
|
||||
player_id=player.id,
|
||||
player_name=player.name,
|
||||
season=season,
|
||||
has_batting=bool(batting_stats),
|
||||
has_pitching=bool(pitching_stats),
|
||||
user_id=user_id)
|
||||
self.logger.info(
|
||||
"PlayerStatsView initialized",
|
||||
player_id=player.id,
|
||||
player_name=player.name,
|
||||
season=season,
|
||||
has_batting=bool(batting_stats),
|
||||
has_pitching=bool(pitching_stats),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@discord.ui.button(
|
||||
label="Show Batting Stats",
|
||||
style=discord.ButtonStyle.primary,
|
||||
emoji="💥",
|
||||
row=0
|
||||
label="Show Batting Stats", style=discord.ButtonStyle.primary, emoji="💥", row=0
|
||||
)
|
||||
async def toggle_batting_button(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
button: discord.ui.Button
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||
):
|
||||
"""Toggle batting statistics visibility."""
|
||||
self.increment_interaction_count()
|
||||
self.show_batting = not self.show_batting
|
||||
|
||||
# Update button label
|
||||
button.label = "Hide Batting Stats" if self.show_batting else "Show Batting Stats"
|
||||
button.label = (
|
||||
"Hide Batting Stats" if self.show_batting else "Show Batting Stats"
|
||||
)
|
||||
|
||||
self.logger.info("Batting stats toggled",
|
||||
player_id=self.player.id,
|
||||
show_batting=self.show_batting,
|
||||
user_id=interaction.user.id)
|
||||
self.logger.info(
|
||||
"Batting stats toggled",
|
||||
player_id=self.player.id,
|
||||
show_batting=self.show_batting,
|
||||
user_id=interaction.user.id,
|
||||
)
|
||||
|
||||
# Rebuild and update embed
|
||||
await self._update_embed(interaction)
|
||||
@ -107,24 +111,26 @@ class PlayerStatsView(BaseView):
|
||||
label="Show Pitching Stats",
|
||||
style=discord.ButtonStyle.primary,
|
||||
emoji="⚾",
|
||||
row=0
|
||||
row=0,
|
||||
)
|
||||
async def toggle_pitching_button(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
button: discord.ui.Button
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||
):
|
||||
"""Toggle pitching statistics visibility."""
|
||||
self.increment_interaction_count()
|
||||
self.show_pitching = not self.show_pitching
|
||||
|
||||
# Update button label
|
||||
button.label = "Hide Pitching Stats" if self.show_pitching else "Show Pitching Stats"
|
||||
button.label = (
|
||||
"Hide Pitching Stats" if self.show_pitching else "Show Pitching Stats"
|
||||
)
|
||||
|
||||
self.logger.info("Pitching stats toggled",
|
||||
player_id=self.player.id,
|
||||
show_pitching=self.show_pitching,
|
||||
user_id=interaction.user.id)
|
||||
self.logger.info(
|
||||
"Pitching stats toggled",
|
||||
player_id=self.player.id,
|
||||
show_pitching=self.show_pitching,
|
||||
user_id=interaction.user.id,
|
||||
)
|
||||
|
||||
# Rebuild and update embed
|
||||
await self._update_embed(interaction)
|
||||
@ -143,20 +149,24 @@ class PlayerStatsView(BaseView):
|
||||
# Update the message with new embed
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
|
||||
self.logger.debug("Embed updated successfully",
|
||||
show_batting=self.show_batting,
|
||||
show_pitching=self.show_pitching)
|
||||
self.logger.debug(
|
||||
"Embed updated successfully",
|
||||
show_batting=self.show_batting,
|
||||
show_pitching=self.show_pitching,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to update embed", error=e, exc_info=True)
|
||||
self.logger.error("Failed to update embed", error=e)
|
||||
|
||||
# Try to send error message
|
||||
try:
|
||||
error_embed = EmbedTemplate.error(
|
||||
title="Update Failed",
|
||||
description="Failed to update player statistics. Please try again."
|
||||
description="Failed to update player statistics. Please try again.",
|
||||
)
|
||||
await interaction.response.send_message(
|
||||
embed=error_embed, ephemeral=True
|
||||
)
|
||||
await interaction.response.send_message(embed=error_embed, ephemeral=True)
|
||||
except Exception:
|
||||
self.logger.error("Failed to send error message", exc_info=True)
|
||||
|
||||
@ -181,23 +191,16 @@ class PlayerStatsView(BaseView):
|
||||
# Add injury indicator emoji if player is injured
|
||||
title = f"🤕 {player.name}" if player.il_return is not None else player.name
|
||||
|
||||
embed = EmbedTemplate.create_base_embed(
|
||||
title=title,
|
||||
color=embed_color
|
||||
)
|
||||
embed = EmbedTemplate.create_base_embed(title=title, color=embed_color)
|
||||
|
||||
# Basic info section (always visible)
|
||||
embed.add_field(
|
||||
name="Position",
|
||||
value=player.primary_position,
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(name="Position", value=player.primary_position, inline=True)
|
||||
|
||||
if hasattr(player, 'team') and player.team:
|
||||
if hasattr(player, "team") and player.team:
|
||||
embed.add_field(
|
||||
name="Team",
|
||||
value=f"{player.team.abbrev} - {player.team.sname}",
|
||||
inline=True
|
||||
inline=True,
|
||||
)
|
||||
|
||||
# Add Major League affiliate if this is a Minor League team
|
||||
@ -205,55 +208,33 @@ class PlayerStatsView(BaseView):
|
||||
major_affiliate = await player.team.major_league_affiliate()
|
||||
if major_affiliate:
|
||||
embed.add_field(
|
||||
name="Major Affiliate",
|
||||
value=major_affiliate,
|
||||
inline=True
|
||||
name="Major Affiliate", value=major_affiliate, inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="sWAR",
|
||||
value=f"{player.wara:.2f}",
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(name="sWAR", value=f"{player.wara:.2f}", inline=True)
|
||||
|
||||
embed.add_field(
|
||||
name="Player ID",
|
||||
value=str(player.id),
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(name="Player ID", value=str(player.id), inline=True)
|
||||
|
||||
# All positions if multiple
|
||||
if len(player.positions) > 1:
|
||||
embed.add_field(
|
||||
name="Positions",
|
||||
value=", ".join(player.positions),
|
||||
inline=True
|
||||
name="Positions", value=", ".join(player.positions), inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Season",
|
||||
value=str(season),
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(name="Season", value=str(season), inline=True)
|
||||
|
||||
# Always show injury rating
|
||||
embed.add_field(
|
||||
name="Injury Rating",
|
||||
value=player.injury_rating or "N/A",
|
||||
inline=True
|
||||
name="Injury Rating", value=player.injury_rating or "N/A", inline=True
|
||||
)
|
||||
|
||||
# Show injury return date only if player is currently injured
|
||||
if player.il_return:
|
||||
embed.add_field(
|
||||
name="Injury Return",
|
||||
value=player.il_return,
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(name="Injury Return", value=player.il_return, inline=True)
|
||||
|
||||
# Add batting stats if visible and available
|
||||
if self.show_batting and self.batting_stats:
|
||||
embed.add_field(name='', value='', inline=False)
|
||||
embed.add_field(name="", value="", inline=False)
|
||||
|
||||
self.logger.debug("Adding batting statistics to embed")
|
||||
batting_stats = self.batting_stats
|
||||
@ -269,11 +250,7 @@ class PlayerStatsView(BaseView):
|
||||
"╰─────────────╯\n"
|
||||
"```"
|
||||
)
|
||||
embed.add_field(
|
||||
name="Rate Stats",
|
||||
value=rate_stats,
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(name="Rate Stats", value=rate_stats, inline=True)
|
||||
|
||||
count_stats = (
|
||||
"```\n"
|
||||
@ -288,15 +265,11 @@ class PlayerStatsView(BaseView):
|
||||
"╰───────────╯\n"
|
||||
"```"
|
||||
)
|
||||
embed.add_field(
|
||||
name='Counting Stats',
|
||||
value=count_stats,
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(name="Counting Stats", value=count_stats, inline=True)
|
||||
|
||||
# Add pitching stats if visible and available
|
||||
if self.show_pitching and self.pitching_stats:
|
||||
embed.add_field(name='', value='', inline=False)
|
||||
embed.add_field(name="", value="", inline=False)
|
||||
|
||||
self.logger.debug("Adding pitching statistics to embed")
|
||||
pitching_stats = self.pitching_stats
|
||||
@ -313,11 +286,7 @@ class PlayerStatsView(BaseView):
|
||||
"╰─────────────╯\n"
|
||||
"```"
|
||||
)
|
||||
embed.add_field(
|
||||
name="Record Stats",
|
||||
value=record_stats,
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(name="Record Stats", value=record_stats, inline=True)
|
||||
|
||||
strikeout_stats = (
|
||||
"```\n"
|
||||
@ -329,11 +298,7 @@ class PlayerStatsView(BaseView):
|
||||
"╰──────────╯\n"
|
||||
"```"
|
||||
)
|
||||
embed.add_field(
|
||||
name='Counting Stats',
|
||||
value=strikeout_stats,
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(name="Counting Stats", value=strikeout_stats, inline=True)
|
||||
|
||||
# Add a note if no stats are visible
|
||||
if not self.show_batting and not self.show_pitching:
|
||||
@ -341,37 +306,46 @@ class PlayerStatsView(BaseView):
|
||||
embed.add_field(
|
||||
name="📊 Statistics",
|
||||
value="Click the buttons below to show statistics.",
|
||||
inline=False
|
||||
inline=False,
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="📊 Statistics",
|
||||
value="No statistics available for this season.",
|
||||
inline=False
|
||||
inline=False,
|
||||
)
|
||||
|
||||
# Set player card as main image
|
||||
if player.image:
|
||||
embed.set_image(url=player.image)
|
||||
self.logger.debug("Player card image added to embed", image_url=player.image)
|
||||
self.logger.debug(
|
||||
"Player card image added to embed", image_url=player.image
|
||||
)
|
||||
|
||||
# Set thumbnail with priority: fancycard → headshot → team logo
|
||||
thumbnail_url = None
|
||||
thumbnail_source = None
|
||||
|
||||
if hasattr(player, 'vanity_card') and player.vanity_card:
|
||||
if hasattr(player, "vanity_card") and player.vanity_card:
|
||||
thumbnail_url = player.vanity_card
|
||||
thumbnail_source = "fancycard"
|
||||
elif hasattr(player, 'headshot') and player.headshot:
|
||||
elif hasattr(player, "headshot") and player.headshot:
|
||||
thumbnail_url = player.headshot
|
||||
thumbnail_source = "headshot"
|
||||
elif hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail:
|
||||
elif (
|
||||
hasattr(player, "team")
|
||||
and player.team
|
||||
and hasattr(player.team, "thumbnail")
|
||||
and player.team.thumbnail
|
||||
):
|
||||
thumbnail_url = player.team.thumbnail
|
||||
thumbnail_source = "team logo"
|
||||
|
||||
if thumbnail_url:
|
||||
embed.set_thumbnail(url=thumbnail_url)
|
||||
self.logger.debug(f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url)
|
||||
self.logger.debug(
|
||||
f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url
|
||||
)
|
||||
|
||||
# Footer with player ID
|
||||
footer_text = f"Player ID: {player.id}"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user