From 9e43ffa1c1aa0d8ab594df99507bdc2dac43cf9d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 19 Feb 2026 21:45:56 -0600 Subject: [PATCH 1/6] feat: add local deploy script for production deploys Automates the SSH-to-akamai deploy workflow: pulls latest image, restarts the container, and verifies health. Includes pre-deploy checks (dirty git warning, confirmation prompt) and prints a rollback command if the image changed. Co-Authored-By: Claude Opus 4.6 --- .scripts/deploy.sh | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100755 .scripts/deploy.sh diff --git a/.scripts/deploy.sh b/.scripts/deploy.sh new file mode 100755 index 0000000..a099692 --- /dev/null +++ b/.scripts/deploy.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Deploy Discord Bot v2 to production (akamai) +# Pulls latest Docker image and restarts the container. +# +# Usage: .scripts/deploy.sh [-y] +# -y Skip confirmation prompt + +set -euo pipefail + +SSH_CMD="ssh -i ~/.ssh/cloud_servers_rsa root@akamai" +REMOTE_DIR="/root/container-data/major-domo" +SERVICE="discord-app" +CONTAINER="major-domo-discord-app-1" +IMAGE="manticorum67/major-domo-discordapp:latest" + +SKIP_CONFIRM=false +[[ "${1:-}" == "-y" ]] && SKIP_CONFIRM=true + +# --- Pre-deploy checks --- + +if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then + echo "WARNING: You have uncommitted changes." + git status --short + echo "" +fi + +BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") +COMMIT=$(git log -1 --format='%h %s' 2>/dev/null || echo "unknown") +echo "Branch: ${BRANCH}" +echo "Latest: ${COMMIT}" +echo "Target: akamai (${IMAGE})" +echo "" + +if [[ "$SKIP_CONFIRM" != true ]]; then + read -rp "Deploy to production? [y/N] " answer + [[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } + echo "" +fi + +# --- Save previous image for rollback --- + +PREV_DIGEST=$($SSH_CMD "docker inspect --format='{{index .RepoDigests 0}}' ${IMAGE} 2>/dev/null" || echo "unknown") + +# --- Deploy --- + +echo "==> Pulling latest image for ${SERVICE}..." +$SSH_CMD "cd ${REMOTE_DIR} && docker compose pull ${SERVICE}" + +echo "==> Restarting ${SERVICE}..." +$SSH_CMD "cd ${REMOTE_DIR} && docker compose up -d ${SERVICE}" + +echo "==> Waiting 5s for container to start..." +sleep 5 + +echo "==> Container status:" +$SSH_CMD "docker ps --filter name=${CONTAINER} --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'" + +echo "" +echo "==> Last 10 log lines:" +$SSH_CMD "docker logs --tail 10 ${CONTAINER}" + +NEW_DIGEST=$($SSH_CMD "docker inspect --format='{{index .RepoDigests 0}}' ${IMAGE} 2>/dev/null || docker inspect --format='{{.Image}}' ${CONTAINER}") +echo "" +echo "==> Image digest: ${NEW_DIGEST}" + +if [[ "$PREV_DIGEST" == "$NEW_DIGEST" ]]; then + echo " (unchanged from previous deploy)" +fi + +# --- Rollback command --- + +if [[ "$PREV_DIGEST" != "unknown" && "$PREV_DIGEST" != "$NEW_DIGEST" ]]; then + echo "" + echo "==> To rollback:" + echo " ssh -i ~/.ssh/cloud_servers_rsa root@akamai \\" + echo " \"cd ${REMOTE_DIR} && docker pull ${PREV_DIGEST} && docker tag ${PREV_DIGEST} ${IMAGE} && docker compose up -d ${SERVICE}\"" +fi + +echo "" +echo "Deploy complete." From 5af62171dae4cf748fc565cb9d89e2b529d29ea7 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 19 Feb 2026 22:28:47 -0600 Subject: [PATCH 2/6] chore: remove obsolete MoveAction test stubs Closes #16 Co-Authored-By: Claude Opus 4.6 --- tests/test_commands_dropadd.py | 315 ++++++++++++++++++++------------- 1 file changed, 192 insertions(+), 123 deletions(-) diff --git a/tests/test_commands_dropadd.py b/tests/test_commands_dropadd.py index 1a747a7..ce5376e 100644 --- a/tests/test_commands_dropadd.py +++ b/tests/test_commands_dropadd.py @@ -3,6 +3,7 @@ Tests for /dropadd Discord Commands Validates the Discord command interface, autocomplete, and user interactions. """ + import pytest from unittest.mock import AsyncMock, MagicMock, patch import discord @@ -18,7 +19,7 @@ from tests.factories import PlayerFactory, TeamFactory class TestDropAddCommands: """Test DropAddCommands Discord command functionality.""" - + @pytest.fixture def mock_bot(self): """Create mock Discord bot.""" @@ -26,12 +27,12 @@ class TestDropAddCommands: bot.user = MagicMock() bot.user.id = 123456789 return bot - + @pytest.fixture def commands_cog(self, mock_bot): """Create DropAddCommands cog instance.""" return DropAddCommands(mock_bot) - + @pytest.fixture def mock_interaction(self): """Create mock Discord interaction.""" @@ -47,88 +48,108 @@ class TestDropAddCommands: interaction.guild = MagicMock() interaction.guild.id = 669356687294988350 # Test guild ID matching config return interaction - + @pytest.fixture def mock_team(self): """Create mock team data.""" return TeamFactory.west_virginia() - + @pytest.fixture def mock_player(self): """Create mock player data.""" return PlayerFactory.mike_trout() - + @pytest.mark.asyncio async def test_player_autocomplete_success(self, commands_cog, mock_interaction): """Test successful player autocomplete.""" mock_players = [ PlayerFactory.mike_trout(id=1), - PlayerFactory.ronald_acuna(id=2) + PlayerFactory.ronald_acuna(id=2), ] - with patch('utils.autocomplete.player_service') as mock_service: + with patch("utils.autocomplete.player_service") as mock_service: mock_service.search_players = AsyncMock(return_value=mock_players) from utils.autocomplete import player_autocomplete - choices = await player_autocomplete(mock_interaction, 'Trout') + + choices = await player_autocomplete(mock_interaction, "Trout") assert len(choices) == 2 - assert choices[0].name == 'Mike Trout (CF)' - assert choices[0].value == 'Mike Trout' - assert choices[1].name == 'Ronald Acuna Jr. (OF)' - assert choices[1].value == 'Ronald Acuna Jr.' - + assert choices[0].name == "Mike Trout (CF)" + assert choices[0].value == "Mike Trout" + assert choices[1].name == "Ronald Acuna Jr. (OF)" + assert choices[1].value == "Ronald Acuna Jr." + @pytest.mark.asyncio async def test_player_autocomplete_with_team(self, commands_cog, mock_interaction): """Test player autocomplete with team information.""" - mock_team = TeamFactory.create(id=499, abbrev='LAA', sname='Angels', lname='Los Angeles Angels') + mock_team = TeamFactory.create( + id=499, abbrev="LAA", sname="Angels", lname="Los Angeles Angels" + ) mock_player = PlayerFactory.mike_trout(id=1) mock_player.team = mock_team # Add team info - with patch('utils.autocomplete.player_service') as mock_service: + with patch("utils.autocomplete.player_service") as mock_service: mock_service.search_players = AsyncMock(return_value=[mock_player]) from utils.autocomplete import player_autocomplete - choices = await player_autocomplete(mock_interaction, 'Trout') + + choices = await player_autocomplete(mock_interaction, "Trout") assert len(choices) == 1 - assert choices[0].name == 'Mike Trout (CF - LAA)' - assert choices[0].value == 'Mike Trout' - + assert choices[0].name == "Mike Trout (CF - LAA)" + assert choices[0].value == "Mike Trout" + @pytest.mark.asyncio - async def test_player_autocomplete_short_input(self, commands_cog, mock_interaction): + async def test_player_autocomplete_short_input( + self, commands_cog, mock_interaction + ): """Test player autocomplete with short input returns empty.""" from utils.autocomplete import player_autocomplete - choices = await player_autocomplete(mock_interaction, 'T') + + choices = await player_autocomplete(mock_interaction, "T") assert len(choices) == 0 - + @pytest.mark.asyncio - async def test_player_autocomplete_error_handling(self, commands_cog, mock_interaction): + async def test_player_autocomplete_error_handling( + self, commands_cog, mock_interaction + ): """Test player autocomplete error handling.""" - with patch('utils.autocomplete.player_service') as mock_service: + with patch("utils.autocomplete.player_service") as mock_service: mock_service.search_players.side_effect = Exception("API Error") from utils.autocomplete import player_autocomplete - choices = await player_autocomplete(mock_interaction, 'Trout') + + choices = await player_autocomplete(mock_interaction, "Trout") assert len(choices) == 0 - + @pytest.mark.asyncio async def test_dropadd_command_no_team(self, commands_cog, mock_interaction): """Test /dropadd command when user has no team.""" - with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: + with patch( + "commands.transactions.dropadd.validate_user_has_team" + ) as mock_validate: mock_validate.return_value = None await commands_cog.dropadd.callback(commands_cog, mock_interaction) mock_interaction.response.defer.assert_called_once() # validate_user_has_team sends its own error message, command just returns mock_validate.assert_called_once_with(mock_interaction) - + @pytest.mark.asyncio - async def test_dropadd_command_success_no_params(self, commands_cog, mock_interaction, mock_team): + async def test_dropadd_command_success_no_params( + self, commands_cog, mock_interaction, mock_team + ): """Test /dropadd command success without parameters.""" - with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: - with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder: - with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed: + with patch( + "commands.transactions.dropadd.validate_user_has_team" + ) as mock_validate: + with patch( + "commands.transactions.dropadd.get_transaction_builder" + ) as mock_get_builder: + with patch( + "commands.transactions.dropadd.create_transaction_embed" + ) as mock_create_embed: mock_validate.return_value = mock_team mock_builder = MagicMock() @@ -143,17 +164,29 @@ class TestDropAddCommands: # Verify flow mock_interaction.response.defer.assert_called_once() mock_validate.assert_called_once_with(mock_interaction) - mock_get_builder.assert_called_once_with(mock_interaction.user.id, mock_team) - mock_create_embed.assert_called_once_with(mock_builder, command_name='/dropadd') + mock_get_builder.assert_called_once_with( + mock_interaction.user.id, mock_team + ) + mock_create_embed.assert_called_once_with( + mock_builder, command_name="/dropadd" + ) mock_interaction.followup.send.assert_called_once() - + @pytest.mark.asyncio - async def test_dropadd_command_with_quick_move(self, commands_cog, mock_interaction, mock_team): + async def test_dropadd_command_with_quick_move( + self, commands_cog, mock_interaction, mock_team + ): """Test /dropadd command with quick move parameters.""" - with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: - with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder: - with patch.object(commands_cog, '_add_quick_move') as mock_add_quick: - with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed: + with patch( + "commands.transactions.dropadd.validate_user_has_team" + ) as mock_validate: + with patch( + "commands.transactions.dropadd.get_transaction_builder" + ) as mock_get_builder: + with patch.object(commands_cog, "_add_quick_move") as mock_add_quick: + with patch( + "commands.transactions.dropadd.create_transaction_embed" + ) as mock_create_embed: mock_validate.return_value = mock_team mock_builder = MagicMock() @@ -162,17 +195,18 @@ class TestDropAddCommands: mock_add_quick.return_value = (True, "") mock_create_embed.return_value = MagicMock() - await commands_cog.dropadd.callback(commands_cog, + await commands_cog.dropadd.callback( + commands_cog, mock_interaction, - player='Mike Trout', - destination='ml' + player="Mike Trout", + destination="ml", ) # Verify quick move was attempted mock_add_quick.assert_called_once_with( - mock_builder, 'Mike Trout', 'ml' + mock_builder, "Mike Trout", "ml" ) - + @pytest.mark.asyncio async def test_add_quick_move_success(self, commands_cog, mock_team, mock_player): """Test successful quick move addition.""" @@ -185,45 +219,49 @@ class TestDropAddCommands: mock_builder._current_roster.minor_league_players = [] mock_builder._current_roster.il_players = [] - with patch('commands.transactions.dropadd.player_service') as mock_service: + with patch("commands.transactions.dropadd.player_service") as mock_service: mock_service.search_players = AsyncMock(return_value=[mock_player]) success, error_message = await commands_cog._add_quick_move( - mock_builder, 'Mike Trout', 'ml' + mock_builder, "Mike Trout", "ml" ) assert success is True assert error_message == "" - mock_service.search_players.assert_called_once_with('Mike Trout', limit=10, season=13) + mock_service.search_players.assert_called_once_with( + "Mike Trout", limit=10, season=13 + ) mock_builder.add_move.assert_called_once() - + @pytest.mark.asyncio async def test_add_quick_move_player_not_found(self, commands_cog, mock_team): """Test quick move when player not found.""" mock_builder = MagicMock() mock_builder.team = mock_team - - with patch('commands.transactions.dropadd.player_service') as mock_service: + + with patch("commands.transactions.dropadd.player_service") as mock_service: mock_service.search_players = AsyncMock(return_value=[]) - + success, error_message = await commands_cog._add_quick_move( - mock_builder, 'Nonexistent Player', 'ml' + mock_builder, "Nonexistent Player", "ml" ) assert success is False assert "not found" in error_message - + @pytest.mark.asyncio - async def test_add_quick_move_invalid_action(self, commands_cog, mock_team, mock_player): + async def test_add_quick_move_invalid_action( + self, commands_cog, mock_team, mock_player + ): """Test quick move with invalid action.""" mock_builder = MagicMock() mock_builder.team = mock_team - with patch('commands.transactions.dropadd.player_service') as mock_service: + with patch("commands.transactions.dropadd.player_service") as mock_service: mock_service.search_players = AsyncMock(return_value=[mock_player]) success, error_message = await commands_cog._add_quick_move( - mock_builder, 'Mike Trout', 'invalid_destination' + mock_builder, "Mike Trout", "invalid_destination" ) assert success is False @@ -244,11 +282,11 @@ class TestDropAddCommands: mock_player.name = "Mike Trout" mock_player.team = other_team - with patch('commands.transactions.dropadd.player_service') as mock_service: + with patch("commands.transactions.dropadd.player_service") as mock_service: mock_service.search_players = AsyncMock(return_value=[mock_player]) success, error_message = await commands_cog._add_quick_move( - mock_builder, 'Mike Trout', 'ml' + mock_builder, "Mike Trout", "ml" ) assert success is False @@ -270,50 +308,55 @@ class TestDropAddCommands: mock_builder._current_roster.il_players = [] # Create a Free Agent team and player - fa_team = TeamFactory.create(id=1, abbrev="FA", sname="Free Agency", lname="Free Agency") + fa_team = TeamFactory.create( + id=1, abbrev="FA", sname="Free Agency", lname="Free Agency" + ) fa_player = PlayerFactory.create(id=12472, name="Mike Trout", team_id=1) fa_player.team = fa_team - with patch('commands.transactions.dropadd.player_service') as mock_service: + with patch("commands.transactions.dropadd.player_service") as mock_service: mock_service.search_players = AsyncMock(return_value=[fa_player]) success, error_message = await commands_cog._add_quick_move( - mock_builder, 'Mike Trout', 'ml' + mock_builder, "Mike Trout", "ml" ) assert success is True assert error_message == "" - # TODO: These tests are for obsolete MoveAction-based functionality - # The transaction system now uses from_roster/to_roster directly - # def test_determine_roster_types_add(self, commands_cog): - # def test_determine_roster_types_drop(self, commands_cog): - # def test_determine_roster_types_recall(self, commands_cog): - # def test_determine_roster_types_demote(self, commands_cog): - pass # Placeholder - @pytest.mark.asyncio async def test_clear_transaction_command(self, commands_cog, mock_interaction): """Test /cleartransaction command.""" - with patch('commands.transactions.dropadd.clear_transaction_builder') as mock_clear: - await commands_cog.clear_transaction.callback(commands_cog, mock_interaction) - + with patch( + "commands.transactions.dropadd.clear_transaction_builder" + ) as mock_clear: + await commands_cog.clear_transaction.callback( + commands_cog, mock_interaction + ) + mock_clear.assert_called_once_with(mock_interaction.user.id) mock_interaction.response.send_message.assert_called_once() - + # Check success message call_args = mock_interaction.response.send_message.call_args assert "transaction builder has been cleared" in call_args[0][0] - assert call_args[1]['ephemeral'] is True - + assert call_args[1]["ephemeral"] is True @pytest.mark.asyncio - async def test_dropadd_first_move_shows_full_embed(self, commands_cog, mock_interaction, mock_team): + async def test_dropadd_first_move_shows_full_embed( + self, commands_cog, mock_interaction, mock_team + ): """Test /dropadd command with first move shows full interactive embed.""" - with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: - with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder: - with patch.object(commands_cog, '_add_quick_move') as mock_add_quick: - with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed: + with patch( + "commands.transactions.dropadd.validate_user_has_team" + ) as mock_validate: + with patch( + "commands.transactions.dropadd.get_transaction_builder" + ) as mock_get_builder: + with patch.object(commands_cog, "_add_quick_move") as mock_add_quick: + with patch( + "commands.transactions.dropadd.create_transaction_embed" + ) as mock_create_embed: mock_validate.return_value = mock_team # Create empty builder (first move) @@ -324,76 +367,88 @@ class TestDropAddCommands: mock_add_quick.return_value = (True, "") mock_create_embed.return_value = MagicMock() - await commands_cog.dropadd.callback(commands_cog, + await commands_cog.dropadd.callback( + commands_cog, mock_interaction, - player='Mike Trout', - destination='ml' + player="Mike Trout", + destination="ml", ) # Should show full embed with view (now ephemeral) mock_interaction.followup.send.assert_called_once() call_args = mock_interaction.followup.send.call_args - assert call_args[1]['ephemeral'] is True - assert 'embed' in call_args[1] - assert 'view' in call_args[1] - assert 'content' in call_args[1] + assert call_args[1]["ephemeral"] is True + assert "embed" in call_args[1] + assert "view" in call_args[1] + assert "content" in call_args[1] @pytest.mark.asyncio - async def test_dropadd_append_mode_shows_confirmation(self, commands_cog, mock_interaction, mock_team): + async def test_dropadd_append_mode_shows_confirmation( + self, commands_cog, mock_interaction, mock_team + ): """Test /dropadd command in append mode shows ephemeral confirmation.""" - with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: - with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder: - with patch.object(commands_cog, '_add_quick_move') as mock_add_quick: + with patch( + "commands.transactions.dropadd.validate_user_has_team" + ) as mock_validate: + with patch( + "commands.transactions.dropadd.get_transaction_builder" + ) as mock_get_builder: + with patch.object(commands_cog, "_add_quick_move") as mock_add_quick: mock_validate.return_value = mock_team # Create builder with existing moves (append mode) mock_builder = MagicMock() mock_builder.is_empty = False mock_builder.move_count = 2 - mock_builder.validate_transaction = AsyncMock(return_value=MagicMock( - is_legal=True, - major_league_count=25, - minor_league_count=10, - warnings=[], - errors=[], - suggestions=[] - )) + mock_builder.validate_transaction = AsyncMock( + return_value=MagicMock( + is_legal=True, + major_league_count=25, + minor_league_count=10, + warnings=[], + errors=[], + suggestions=[], + ) + ) mock_get_builder.return_value = mock_builder mock_add_quick.return_value = (True, "") - with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed: + with patch( + "commands.transactions.dropadd.create_transaction_embed" + ) as mock_create_embed: mock_create_embed.return_value = MagicMock() - await commands_cog.dropadd.callback(commands_cog, + await commands_cog.dropadd.callback( + commands_cog, mock_interaction, - player='Kevin Ginkel', - destination='ml' + player="Kevin Ginkel", + destination="ml", ) # Should show embed with ephemeral confirmation mock_interaction.followup.send.assert_called_once() call_args = mock_interaction.followup.send.call_args - assert call_args[1]['ephemeral'] is True - assert 'embed' in call_args[1] - assert 'view' in call_args[1] - content = call_args[1]['content'] + assert call_args[1]["ephemeral"] is True + assert "embed" in call_args[1] + assert "view" in call_args[1] + content = call_args[1]["content"] assert "Added Kevin Ginkel → ML" in content assert "Transaction now has 2 moves" in content class TestDropAddCommandsIntegration: """Integration tests for dropadd commands with real-like data flows.""" - + @pytest.fixture def mock_bot(self): """Create mock Discord bot.""" return MagicMock() - + @pytest.fixture def commands_cog(self, mock_bot): """Create DropAddCommands cog instance.""" return DropAddCommands(mock_bot) - + @pytest.mark.asyncio async def test_full_dropadd_workflow(self, commands_cog): """Test complete dropadd workflow from command to builder creation.""" @@ -407,13 +462,23 @@ class TestDropAddCommandsIntegration: mock_player = PlayerFactory.mike_trout(id=12472) - with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: - with patch('commands.transactions.dropadd.player_service') as mock_player_service: - with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder: - with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed: + with patch( + "commands.transactions.dropadd.validate_user_has_team" + ) as mock_validate: + with patch( + "commands.transactions.dropadd.player_service" + ) as mock_player_service: + with patch( + "commands.transactions.dropadd.get_transaction_builder" + ) as mock_get_builder: + with patch( + "commands.transactions.dropadd.create_transaction_embed" + ) as mock_create_embed: # Setup mocks mock_validate.return_value = mock_team - mock_player_service.search_players = AsyncMock(return_value=[mock_player]) + mock_player_service.search_players = AsyncMock( + return_value=[mock_player] + ) mock_builder = TransactionBuilder(mock_team, 123456789, 13) mock_get_builder.return_value = mock_builder @@ -421,13 +486,15 @@ class TestDropAddCommandsIntegration: # Mock the async function async def mock_create_embed_func(builder, command_name=None): return MagicMock() + mock_create_embed.side_effect = mock_create_embed_func # Execute command with parameters - await commands_cog.dropadd.callback(commands_cog, + await commands_cog.dropadd.callback( + commands_cog, mock_interaction, - player='Mike Trout', - destination='ml' + player="Mike Trout", + destination="ml", ) # Verify the builder has the move @@ -436,7 +503,7 @@ class TestDropAddCommandsIntegration: assert move.player == mock_player # Note: TransactionMove no longer has 'action' field - uses from_roster/to_roster instead assert move.to_roster == RosterType.MAJOR_LEAGUE - + @pytest.mark.asyncio async def test_error_recovery_in_workflow(self, commands_cog): """Test error recovery in dropadd workflow.""" @@ -446,7 +513,9 @@ class TestDropAddCommandsIntegration: mock_interaction.guild = MagicMock() mock_interaction.guild.id = 669356687294988350 - with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: + with patch( + "commands.transactions.dropadd.validate_user_has_team" + ) as mock_validate: # Simulate API error mock_validate.side_effect = Exception("API Error") @@ -455,4 +524,4 @@ class TestDropAddCommandsIntegration: await commands_cog.dropadd.callback(commands_cog, mock_interaction) # Should have deferred before the error occurred - mock_interaction.response.defer.assert_called_once() \ No newline at end of file + mock_interaction.response.defer.assert_called_once() From f4be20afb346716bbc057fe3fc655649c5074782 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Feb 2026 09:54:53 -0600 Subject: [PATCH 3/6] fix: address 7 security issues across the codebase - Remove hardcoded Giphy API key from config.py, load from env var (#19) - URL-encode query parameters in APIClient._add_params (#20) - URL-encode Giphy search phrases before building request URLs (#21) - Replace internal exception details with generic messages to users (#22) - Replace all bare except: with except Exception: (#23) - Guard interaction.guild access in has_player_role (#24) - Replace MD5 with SHA-256 for command change detection hash (#32) Co-Authored-By: Claude Opus 4.6 --- api/client.py | 235 ++++++++------- bot.py | 269 +++++++++-------- commands/injuries/management.py | 453 +++++++++++++++++----------- commands/league/submit_scorecard.py | 10 +- config.py | 51 ++-- services/giphy_service.py | 189 +++++++----- tasks/transaction_freeze.py | 428 ++++++++++++++++---------- views/transaction_embed.py | 6 +- 8 files changed, 976 insertions(+), 665 deletions(-) diff --git a/api/client.py b/api/client.py index 553ac04..4ce5425 100644 --- a/api/client.py +++ b/api/client.py @@ -4,6 +4,7 @@ API client for Discord Bot v2.0 Modern aiohttp-based HTTP client for communicating with the database API. Provides connection pooling, proper error handling, and session management. """ + import aiohttp import logging from typing import Optional, List, Dict, Any, Union @@ -13,13 +14,13 @@ from contextlib import asynccontextmanager from config import get_config from exceptions import APIException -logger = logging.getLogger(f'{__name__}.APIClient') +logger = logging.getLogger(f"{__name__}.APIClient") class APIClient: """ Async HTTP client for SBA database API communication. - + Features: - Connection pooling with proper session management - Bearer token authentication @@ -27,15 +28,15 @@ class APIClient: - Comprehensive error handling - Debug logging with response truncation """ - + def __init__(self, base_url: Optional[str] = None, api_token: Optional[str] = None): """ Initialize API client with configuration. - + Args: base_url: Override default database URL from config api_token: Override default API token from config - + Raises: ValueError: If required configuration is missing """ @@ -43,24 +44,29 @@ class APIClient: self.base_url = base_url or config.db_url self.api_token = api_token or config.api_token self._session: Optional[aiohttp.ClientSession] = None - + if not self.base_url: raise ValueError("DB_URL must be configured") if not self.api_token: raise ValueError("API_TOKEN must be configured") - + logger.debug(f"APIClient initialized with base_url: {self.base_url}") - + @property def headers(self) -> Dict[str, str]: """Get headers with authentication and content type.""" return { - 'Authorization': f'Bearer {self.api_token}', - 'Content-Type': 'application/json', - 'User-Agent': 'SBA-Discord-Bot-v2/1.0' + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + "User-Agent": "SBA-Discord-Bot-v2/1.0", } - - def _build_url(self, endpoint: str, api_version: int = 3, object_id: Optional[Union[int, str]] = None) -> str: + + def _build_url( + self, + endpoint: str, + api_version: int = 3, + object_id: Optional[Union[int, str]] = None, + ) -> str: """ Build complete API URL from components. @@ -73,35 +79,38 @@ class APIClient: Complete URL for API request """ # Handle already complete URLs - if endpoint.startswith(('http://', 'https://')) or '/api/' in endpoint: + if endpoint.startswith(("http://", "https://")) or "/api/" in endpoint: return endpoint path = f"v{api_version}/{endpoint}" if object_id is not None: # URL-encode the object_id to handle special characters (e.g., colons in moveids) - encoded_id = quote(str(object_id), safe='') + encoded_id = quote(str(object_id), safe="") path += f"/{encoded_id}" - return urljoin(self.base_url.rstrip('/') + '/', path) - + return urljoin(self.base_url.rstrip("/") + "/", path) + def _add_params(self, url: str, params: Optional[List[tuple]] = None) -> str: """ Add query parameters to URL. - + Args: url: Base URL params: List of (key, value) tuples - + Returns: URL with query parameters appended """ if not params: return url - - param_str = "&".join(f"{key}={value}" for key, value in params) + + param_str = "&".join( + f"{quote(str(key), safe='')}={quote(str(value), safe='')}" + for key, value in params + ) separator = "&" if "?" in url else "?" return f"{url}{separator}{param_str}" - + async def _ensure_session(self) -> None: """Ensure aiohttp session exists and is not closed.""" if self._session is None or self._session.closed: @@ -109,53 +118,51 @@ class APIClient: limit=100, # Total connection pool size limit_per_host=30, # Connections per host ttl_dns_cache=300, # DNS cache TTL - use_dns_cache=True + use_dns_cache=True, ) - + timeout = aiohttp.ClientTimeout(total=30, connect=10) - + self._session = aiohttp.ClientSession( - headers=self.headers, - connector=connector, - timeout=timeout + headers=self.headers, connector=connector, timeout=timeout ) - + logger.debug("Created new aiohttp session with connection pooling") - + async def get( self, endpoint: str, object_id: Optional[Union[int, str]] = None, params: Optional[List[tuple]] = None, api_version: int = 3, - timeout: Optional[int] = None + timeout: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Make GET request to API. - + Args: endpoint: API endpoint object_id: Optional object ID params: Query parameters api_version: API version (default: 3) timeout: Request timeout override - + Returns: JSON response data or None for 404 - + Raises: APIException: For HTTP errors or network issues """ url = self._build_url(endpoint, api_version, object_id) url = self._add_params(url, params) - + await self._ensure_session() - + try: logger.debug(f"GET: {endpoint} id: {object_id} params: {params}") - + request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None - + async with self._session.get(url, timeout=request_timeout) as response: if response.status == 404: logger.warning(f"Resource not found: {url}") @@ -169,10 +176,12 @@ class APIClient: elif response.status >= 400: error_text = await response.text() logger.error(f"API error {response.status}: {url} - {error_text}") - raise APIException(f"API request failed with status {response.status}: {error_text}") - + raise APIException( + f"API request failed with status {response.status}: {error_text}" + ) + data = await response.json() - + # Truncate response for logging data_str = str(data) if len(data_str) > 1200: @@ -180,48 +189,50 @@ class APIClient: else: log_data = data_str logger.debug(f"Response: {log_data}") - + return data - + except aiohttp.ClientError as e: logger.error(f"HTTP client error for {url}: {e}") raise APIException(f"Network error: {e}") except Exception as e: logger.error(f"Unexpected error in GET {url}: {e}") raise APIException(f"API call failed: {e}") - + async def post( - self, - endpoint: str, + self, + endpoint: str, data: Dict[str, Any], api_version: int = 3, - timeout: Optional[int] = None + timeout: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Make POST request to API. - + Args: endpoint: API endpoint data: Request payload api_version: API version (default: 3) timeout: Request timeout override - + Returns: JSON response data - + Raises: APIException: For HTTP errors or network issues """ url = self._build_url(endpoint, api_version) - + await self._ensure_session() - + try: logger.debug(f"POST: {endpoint} data: {data}") - + request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None - - async with self._session.post(url, json=data, timeout=request_timeout) as response: + + async with self._session.post( + url, json=data, timeout=request_timeout + ) as response: if response.status == 401: logger.error(f"Authentication failed for POST: {url}") raise APIException("Authentication failed - check API token") @@ -231,10 +242,12 @@ class APIClient: elif response.status not in [200, 201]: error_text = await response.text() logger.error(f"POST error {response.status}: {url} - {error_text}") - raise APIException(f"POST request failed with status {response.status}: {error_text}") - + raise APIException( + f"POST request failed with status {response.status}: {error_text}" + ) + result = await response.json() - + # Truncate response for logging result_str = str(result) if len(result_str) > 1200: @@ -242,50 +255,52 @@ class APIClient: else: log_result = result_str logger.debug(f"POST Response: {log_result}") - + return result - + except aiohttp.ClientError as e: logger.error(f"HTTP client error for POST {url}: {e}") raise APIException(f"Network error: {e}") except Exception as e: logger.error(f"Unexpected error in POST {url}: {e}") raise APIException(f"POST failed: {e}") - + async def put( self, endpoint: str, data: Dict[str, Any], object_id: Optional[Union[int, str]] = None, api_version: int = 3, - timeout: Optional[int] = None + timeout: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Make PUT request to API. - + Args: endpoint: API endpoint data: Request payload object_id: Optional object ID api_version: API version (default: 3) timeout: Request timeout override - + Returns: JSON response data - + Raises: APIException: For HTTP errors or network issues """ url = self._build_url(endpoint, api_version, object_id) - + await self._ensure_session() - + try: logger.debug(f"PUT: {endpoint} id: {object_id} data: {data}") - + request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None - - async with self._session.put(url, json=data, timeout=request_timeout) as response: + + async with self._session.put( + url, json=data, timeout=request_timeout + ) as response: if response.status == 401: logger.error(f"Authentication failed for PUT: {url}") raise APIException("Authentication failed - check API token") @@ -298,19 +313,23 @@ class APIClient: elif response.status not in [200, 201]: error_text = await response.text() logger.error(f"PUT error {response.status}: {url} - {error_text}") - raise APIException(f"PUT request failed with status {response.status}: {error_text}") - + raise APIException( + f"PUT request failed with status {response.status}: {error_text}" + ) + result = await response.json() - logger.debug(f"PUT Response: {str(result)[:1200]}{'...' if len(str(result)) > 1200 else ''}") + logger.debug( + f"PUT Response: {str(result)[:1200]}{'...' if len(str(result)) > 1200 else ''}" + ) return result - + except aiohttp.ClientError as e: logger.error(f"HTTP client error for PUT {url}: {e}") raise APIException(f"Network error: {e}") except Exception as e: logger.error(f"Unexpected error in PUT {url}: {e}") raise APIException(f"PUT failed: {e}") - + async def patch( self, endpoint: str, @@ -318,7 +337,7 @@ class APIClient: object_id: Optional[Union[int, str]] = None, api_version: int = 3, timeout: Optional[int] = None, - use_query_params: bool = False + use_query_params: bool = False, ) -> Optional[Dict[str, Any]]: """ Make PATCH request to API. @@ -344,13 +363,15 @@ class APIClient: # Handle None values by converting to empty string # The database API's PATCH endpoint treats empty strings as NULL for nullable fields # Example: {'il_return': None} → ?il_return= → Database sets il_return to NULL - params = [(k, '' if v is None else str(v)) for k, v in data.items()] + params = [(k, "" if v is None else str(v)) for k, v in data.items()] url = self._add_params(url, params) await self._ensure_session() try: - logger.debug(f"PATCH: {endpoint} id: {object_id} data: {data} use_query_params: {use_query_params}") + logger.debug( + f"PATCH: {endpoint} id: {object_id} data: {data} use_query_params: {use_query_params}" + ) logger.debug(f"PATCH URL: {url}") request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None @@ -358,10 +379,12 @@ class APIClient: # Use json=data if data is provided and not using query params kwargs = {} if data is not None and not use_query_params: - kwargs['json'] = data + kwargs["json"] = data logger.debug(f"PATCH JSON body: {data}") - async with self._session.patch(url, timeout=request_timeout, **kwargs) as response: + async with self._session.patch( + url, timeout=request_timeout, **kwargs + ) as response: if response.status == 401: logger.error(f"Authentication failed for PATCH: {url}") raise APIException("Authentication failed - check API token") @@ -374,10 +397,14 @@ class APIClient: elif response.status not in [200, 201]: error_text = await response.text() logger.error(f"PATCH error {response.status}: {url} - {error_text}") - raise APIException(f"PATCH request failed with status {response.status}: {error_text}") + raise APIException( + f"PATCH request failed with status {response.status}: {error_text}" + ) result = await response.json() - logger.debug(f"PATCH Response: {str(result)[:1200]}{'...' if len(str(result)) > 1200 else ''}") + logger.debug( + f"PATCH Response: {str(result)[:1200]}{'...' if len(str(result)) > 1200 else ''}" + ) return result except aiohttp.ClientError as e: @@ -386,38 +413,38 @@ class APIClient: except Exception as e: logger.error(f"Unexpected error in PATCH {url}: {e}") raise APIException(f"PATCH failed: {e}") - + async def delete( self, endpoint: str, object_id: Optional[Union[int, str]] = None, api_version: int = 3, - timeout: Optional[int] = None + timeout: Optional[int] = None, ) -> bool: """ Make DELETE request to API. - + Args: endpoint: API endpoint object_id: Optional object ID api_version: API version (default: 3) timeout: Request timeout override - + Returns: True if deletion successful, False if resource not found - + Raises: APIException: For HTTP errors or network issues """ url = self._build_url(endpoint, api_version, object_id) - + await self._ensure_session() - + try: logger.debug(f"DELETE: {endpoint} id: {object_id}") - + request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None - + async with self._session.delete(url, timeout=request_timeout) as response: if response.status == 401: logger.error(f"Authentication failed for DELETE: {url}") @@ -430,30 +457,34 @@ class APIClient: return False elif response.status not in [200, 204]: error_text = await response.text() - logger.error(f"DELETE error {response.status}: {url} - {error_text}") - raise APIException(f"DELETE request failed with status {response.status}: {error_text}") - + logger.error( + f"DELETE error {response.status}: {url} - {error_text}" + ) + raise APIException( + f"DELETE request failed with status {response.status}: {error_text}" + ) + logger.debug(f"DELETE successful: {url}") return True - + except aiohttp.ClientError as e: logger.error(f"HTTP client error for DELETE {url}: {e}") raise APIException(f"Network error: {e}") except Exception as e: logger.error(f"Unexpected error in DELETE {url}: {e}") raise APIException(f"DELETE failed: {e}") - + async def close(self) -> None: """Close the HTTP session and clean up resources.""" if self._session and not self._session.closed: await self._session.close() logger.debug("Closed aiohttp session") - + async def __aenter__(self): """Async context manager entry.""" await self._ensure_session() return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): """Async context manager exit with cleanup.""" await self.close() @@ -463,7 +494,7 @@ class APIClient: async def get_api_client() -> APIClient: """ Get API client as async context manager. - + Usage: async with get_api_client() as client: data = await client.get('players') @@ -482,14 +513,14 @@ _global_client: Optional[APIClient] = None async def get_global_client() -> APIClient: """ Get global API client instance with automatic session management. - + Returns: Shared APIClient instance """ global _global_client if _global_client is None: _global_client = APIClient() - + await _global_client._ensure_session() return _global_client @@ -499,4 +530,4 @@ async def cleanup_global_client() -> None: global _global_client if _global_client: await _global_client.close() - _global_client = None \ No newline at end of file + _global_client = None diff --git a/bot.py b/bot.py index 4e893dc..1bcd26f 100644 --- a/bot.py +++ b/bot.py @@ -3,6 +3,7 @@ Discord Bot v2.0 - Main Entry Point Modern discord.py bot with application commands and proper error handling. """ + import asyncio import hashlib import json @@ -23,89 +24,91 @@ from views.embeds import EmbedTemplate, EmbedColors def setup_logging(): """Configure hybrid logging: human-readable console + structured JSON files.""" from utils.logging import JSONFormatter - + # Create logs directory if it doesn't exist - os.makedirs('logs', exist_ok=True) - + os.makedirs("logs", exist_ok=True) + # Configure root logger config = get_config() - logger = logging.getLogger('discord_bot_v2') + logger = logging.getLogger("discord_bot_v2") logger.setLevel(getattr(logging, config.log_level.upper())) - + # Console handler - detailed format for development debugging console_handler = logging.StreamHandler() console_formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s' + "%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s" ) console_handler.setFormatter(console_formatter) logger.addHandler(console_handler) - + # JSON file handler - structured logging for monitoring and analysis json_handler = RotatingFileHandler( - 'logs/discord_bot_v2.json', - maxBytes=5 * 1024 * 1024, # 5MB - backupCount=5 + "logs/discord_bot_v2.json", maxBytes=5 * 1024 * 1024, backupCount=5 # 5MB ) json_handler.setFormatter(JSONFormatter()) logger.addHandler(json_handler) - + # Configure root logger for third-party libraries (discord.py, aiohttp, etc.) root_logger = logging.getLogger() root_logger.setLevel(getattr(logging, config.log_level.upper())) - + # Add handlers to root logger so third-party loggers inherit them if not root_logger.handlers: # Avoid duplicate handlers root_logger.addHandler(console_handler) root_logger.addHandler(json_handler) - + # Prevent discord_bot_v2 logger from propagating to root to avoid duplicate messages # (bot logs will still appear via its own handlers, third-party logs via root handlers) # To revert: remove the line below and bot logs will appear twice logger.propagate = False - + return logger class SBABot(commands.Bot): """Custom bot class for SBA league management.""" - + def __init__(self): # Configure intents intents = discord.Intents.default() intents.message_content = True # For legacy commands if needed intents.members = True # For member management - + super().__init__( - command_prefix='!', # Legacy prefix, primarily using slash commands + command_prefix="!", # Legacy prefix, primarily using slash commands intents=intents, - description="Major Domo v2.0" + description="Major Domo v2.0", ) - - self.logger = logging.getLogger('discord_bot_v2') - + + self.logger = logging.getLogger("discord_bot_v2") + async def setup_hook(self): """Called when the bot is starting up.""" self.logger.info("Setting up bot...") - + # Load command packages await self._load_command_packages() - + # Initialize cleanup tasks await self._setup_background_tasks() - + # Smart command syncing: auto-sync in development if changes detected; !admin-sync for first sync config = get_config() if config.is_development: if await self._should_sync_commands(): - self.logger.info("Development mode: changes detected, syncing commands...") + self.logger.info( + "Development mode: changes detected, syncing commands..." + ) await self._sync_commands() await self._save_command_hash() else: - self.logger.info("Development mode: no command changes detected, skipping sync") + self.logger.info( + "Development mode: no command changes detected, skipping sync" + ) else: self.logger.info("Production mode: commands loaded but not auto-synced") self.logger.info("Use /admin-sync command to manually sync when needed") - + async def _load_command_packages(self): """Load all command packages with resilient error handling.""" from commands.players import setup_players @@ -146,32 +149,42 @@ class SBABot(commands.Bot): ("gameplay", setup_gameplay), ("dev", setup_dev), # Dev-only commands (admin restricted) ] - + total_successful = 0 total_failed = 0 - + for package_name, setup_func in command_packages: try: self.logger.info(f"Loading {package_name} commands...") successful, failed, failed_modules = await setup_func(self) total_successful += successful total_failed += failed - + if failed == 0: - self.logger.info(f"✅ {package_name} commands loaded successfully ({successful} cogs)") + self.logger.info( + f"✅ {package_name} commands loaded successfully ({successful} cogs)" + ) else: - self.logger.warning(f"⚠️ {package_name} commands partially loaded: {successful} successful, {failed} failed") - + self.logger.warning( + f"⚠️ {package_name} commands partially loaded: {successful} successful, {failed} failed" + ) + except Exception as e: - self.logger.error(f"❌ Failed to load {package_name} package: {e}", exc_info=True) + self.logger.error( + f"❌ Failed to load {package_name} package: {e}", exc_info=True + ) total_failed += 1 - + # Log overall summary if total_failed == 0: - self.logger.info(f"🎉 All command packages loaded successfully ({total_successful} total cogs)") + self.logger.info( + f"🎉 All command packages loaded successfully ({total_successful} total cogs)" + ) else: - self.logger.warning(f"⚠️ Command loading completed with issues: {total_successful} successful, {total_failed} failed") - + self.logger.warning( + f"⚠️ Command loading completed with issues: {total_successful} successful, {total_failed} failed" + ) + async def _setup_background_tasks(self): """Initialize background tasks for the bot.""" try: @@ -179,28 +192,34 @@ class SBABot(commands.Bot): # Initialize custom command cleanup task from tasks.custom_command_cleanup import setup_cleanup_task + self.custom_command_cleanup = setup_cleanup_task(self) # Initialize transaction freeze/thaw task from tasks.transaction_freeze import setup_freeze_task + self.transaction_freeze = setup_freeze_task(self) self.logger.info("✅ Transaction freeze/thaw task started") # Initialize voice channel cleanup service from commands.voice.cleanup_service import setup_voice_cleanup + self.voice_cleanup_service = setup_voice_cleanup(self) self.logger.info("✅ Voice channel cleanup service started") # Initialize live scorebug tracker from tasks.live_scorebug_tracker import setup_scorebug_tracker + self.live_scorebug_tracker = setup_scorebug_tracker(self) self.logger.info("✅ Live scorebug tracker started") self.logger.info("✅ Background tasks initialized successfully") except Exception as e: - self.logger.error(f"❌ Failed to initialize background tasks: {e}", exc_info=True) - + self.logger.error( + f"❌ Failed to initialize background tasks: {e}", exc_info=True + ) + async def _should_sync_commands(self) -> bool: """Check if commands have changed since last sync.""" try: @@ -209,50 +228,51 @@ class SBABot(commands.Bot): for cmd in self.tree.get_commands(): # Handle different command types properly cmd_dict = {} - cmd_dict['name'] = cmd.name - cmd_dict['type'] = type(cmd).__name__ - + cmd_dict["name"] = cmd.name + cmd_dict["type"] = type(cmd).__name__ + # Add description if available (most command types have this) - if hasattr(cmd, 'description'): - cmd_dict['description'] = cmd.description # type: ignore - + if hasattr(cmd, "description"): + cmd_dict["description"] = cmd.description # type: ignore + # Add parameters for Command objects if isinstance(cmd, discord.app_commands.Command): - cmd_dict['parameters'] = [ + cmd_dict["parameters"] = [ { - 'name': param.name, - 'description': param.description, - 'required': param.required, - 'type': str(param.type) - } for param in cmd.parameters + "name": param.name, + "description": param.description, + "required": param.required, + "type": str(param.type), + } + for param in cmd.parameters ] elif isinstance(cmd, discord.app_commands.Group): # For groups, include subcommands - cmd_dict['subcommands'] = [subcmd.name for subcmd in cmd.commands] - + cmd_dict["subcommands"] = [subcmd.name for subcmd in cmd.commands] + commands_data.append(cmd_dict) - + # Sort for consistent hashing - commands_data.sort(key=lambda x: x['name']) - current_hash = hashlib.md5( + commands_data.sort(key=lambda x: x["name"]) + current_hash = hashlib.sha256( json.dumps(commands_data, sort_keys=True).encode() ).hexdigest() - + # Compare with stored hash - hash_file = '.last_command_hash' + hash_file = ".last_command_hash" if os.path.exists(hash_file): - with open(hash_file, 'r') as f: + with open(hash_file, "r") as f: last_hash = f.read().strip() return current_hash != last_hash else: # No previous hash = first run, should sync return True - + except Exception as e: self.logger.warning(f"Error checking command hash: {e}") # If we can't determine changes, err on the side of syncing return True - + async def _save_command_hash(self): """Save current command hash for future comparison.""" try: @@ -261,41 +281,42 @@ class SBABot(commands.Bot): for cmd in self.tree.get_commands(): # Handle different command types properly cmd_dict = {} - cmd_dict['name'] = cmd.name - cmd_dict['type'] = type(cmd).__name__ - + cmd_dict["name"] = cmd.name + cmd_dict["type"] = type(cmd).__name__ + # Add description if available (most command types have this) - if hasattr(cmd, 'description'): - cmd_dict['description'] = cmd.description # type: ignore - + if hasattr(cmd, "description"): + cmd_dict["description"] = cmd.description # type: ignore + # Add parameters for Command objects if isinstance(cmd, discord.app_commands.Command): - cmd_dict['parameters'] = [ + cmd_dict["parameters"] = [ { - 'name': param.name, - 'description': param.description, - 'required': param.required, - 'type': str(param.type) - } for param in cmd.parameters + "name": param.name, + "description": param.description, + "required": param.required, + "type": str(param.type), + } + for param in cmd.parameters ] elif isinstance(cmd, discord.app_commands.Group): # For groups, include subcommands - cmd_dict['subcommands'] = [subcmd.name for subcmd in cmd.commands] - + cmd_dict["subcommands"] = [subcmd.name for subcmd in cmd.commands] + commands_data.append(cmd_dict) - - commands_data.sort(key=lambda x: x['name']) - current_hash = hashlib.md5( + + commands_data.sort(key=lambda x: x["name"]) + current_hash = hashlib.sha256( json.dumps(commands_data, sort_keys=True).encode() ).hexdigest() - + # Save hash to file - with open('.last_command_hash', 'w') as f: + with open(".last_command_hash", "w") as f: f.write(current_hash) - + except Exception as e: self.logger.warning(f"Error saving command hash: {e}") - + async def _sync_commands(self): """Internal method to sync commands.""" config = get_config() @@ -303,54 +324,55 @@ class SBABot(commands.Bot): guild = discord.Object(id=config.guild_id) self.tree.copy_global_to(guild=guild) synced = await self.tree.sync(guild=guild) - self.logger.info(f"Synced {len(synced)} commands to guild {config.guild_id}") + self.logger.info( + f"Synced {len(synced)} commands to guild {config.guild_id}" + ) else: synced = await self.tree.sync() self.logger.info(f"Synced {len(synced)} commands globally") - + async def on_ready(self): """Called when the bot is ready.""" self.logger.info(f"Bot ready! Logged in as {self.user}") self.logger.info(f"Connected to {len(self.guilds)} guilds") - + # Set activity status activity = discord.Activity( - type=discord.ActivityType.watching, - name=random_from_list(STARTUP_WATCHING) + type=discord.ActivityType.watching, name=random_from_list(STARTUP_WATCHING) ) await self.change_presence(activity=activity) - + async def on_error(self, event_method: str, /, *args, **kwargs): """Global error handler for events.""" self.logger.error(f"Error in event {event_method}", exc_info=True) - + async def close(self): """Clean shutdown of the bot.""" self.logger.info("Bot shutting down...") # Stop background tasks - if hasattr(self, 'custom_command_cleanup'): + if hasattr(self, "custom_command_cleanup"): try: self.custom_command_cleanup.cleanup_task.cancel() self.logger.info("Custom command cleanup task stopped") except Exception as e: self.logger.error(f"Error stopping cleanup task: {e}") - if hasattr(self, 'transaction_freeze'): + if hasattr(self, "transaction_freeze"): try: self.transaction_freeze.weekly_loop.cancel() self.logger.info("Transaction freeze/thaw task stopped") except Exception as e: self.logger.error(f"Error stopping transaction freeze task: {e}") - if hasattr(self, 'voice_cleanup_service'): + if hasattr(self, "voice_cleanup_service"): try: self.voice_cleanup_service.cog_unload() self.logger.info("Voice channel cleanup service stopped") except Exception as e: self.logger.error(f"Error stopping voice cleanup service: {e}") - if hasattr(self, 'live_scorebug_tracker'): + if hasattr(self, "live_scorebug_tracker"): try: self.live_scorebug_tracker.update_loop.cancel() self.logger.info("Live scorebug tracker stopped") @@ -369,15 +391,15 @@ bot = SBABot() @bot.tree.command(name="health", description="Check bot and API health status") async def health_command(interaction: discord.Interaction): """Health check command to verify bot and API connectivity.""" - logger = logging.getLogger('discord_bot_v2') - + logger = logging.getLogger("discord_bot_v2") + try: # Check API connectivity api_status = "✅ Connected" try: client = await get_global_client() # Test API with a simple request - result = await client.get('current') + result = await client.get("current") if result: api_status = "✅ Connected" else: @@ -385,69 +407,66 @@ async def health_command(interaction: discord.Interaction): except Exception as e: logger.error(f"API health check failed: {e}") api_status = f"❌ Error: {str(e)}" - + # Bot health info guild_count = len(bot.guilds) - + # Create health status embed - embed = EmbedTemplate.success( - title="🏥 Bot Health Check" - ) - + embed = EmbedTemplate.success(title="🏥 Bot Health Check") + embed.add_field(name="Bot Status", value="✅ Online", inline=True) embed.add_field(name="API Status", value=api_status, inline=True) embed.add_field(name="Guilds", value=str(guild_count), inline=True) embed.add_field(name="Latency", value=f"{bot.latency*1000:.1f}ms", inline=True) - + if bot.user: - embed.set_footer(text=f"Bot: {bot.user.name}", icon_url=bot.user.display_avatar.url) - + embed.set_footer( + text=f"Bot: {bot.user.name}", icon_url=bot.user.display_avatar.url + ) + await interaction.response.send_message(embed=embed, ephemeral=True) - + except Exception as e: logger.error(f"Health check command error: {e}", exc_info=True) await interaction.response.send_message( - f"❌ Health check failed: {str(e)}", - ephemeral=True + f"❌ Health check failed: {str(e)}", ephemeral=True ) @bot.tree.error -async def on_app_command_error(interaction: discord.Interaction, error: discord.app_commands.AppCommandError): +async def on_app_command_error( + interaction: discord.Interaction, error: discord.app_commands.AppCommandError +): """Global error handler for application commands.""" - logger = logging.getLogger('discord_bot_v2') - + logger = logging.getLogger("discord_bot_v2") + # Handle specific error types if isinstance(error, discord.app_commands.CommandOnCooldown): await interaction.response.send_message( f"⏰ Command on cooldown. Try again in {error.retry_after:.1f} seconds.", - ephemeral=True + ephemeral=True, ) elif isinstance(error, discord.app_commands.MissingPermissions): await interaction.response.send_message( - "❌ You don't have permission to use this command.", - ephemeral=True + "❌ You don't have permission to use this command.", ephemeral=True ) elif isinstance(error, discord.app_commands.CommandNotFound): await interaction.response.send_message( "❌ Command not found. Use `/help` to see available commands.", - ephemeral=True + ephemeral=True, ) elif isinstance(error, BotException): # Our custom exceptions - show user-friendly message - await interaction.response.send_message( - f"❌ {str(error)}", - ephemeral=True - ) + await interaction.response.send_message(f"❌ {str(error)}", ephemeral=True) else: # Unexpected errors - log and show generic message logger.error(f"Unhandled command error: {error}", exc_info=True) - + message = "❌ An unexpected error occurred. Please try again." config = get_config() if config.is_development: message += f"\n\nDevelopment error: {str(error)}" - + if interaction.response.is_done(): await interaction.followup.send(message, ephemeral=True) else: @@ -457,12 +476,12 @@ async def on_app_command_error(interaction: discord.Interaction, error: discord. async def main(): """Main entry point.""" logger = setup_logging() - + config = get_config() logger.info("Starting Discord Bot v2.0") logger.info(f"Environment: {config.environment}") logger.info(f"Guild ID: {config.guild_id}") - + try: await bot.start(config.bot_token) except KeyboardInterrupt: @@ -475,4 +494,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/commands/injuries/management.py b/commands/injuries/management.py index be845cc..9e9fb4b 100644 --- a/commands/injuries/management.py +++ b/commands/injuries/management.py @@ -10,6 +10,7 @@ The injury rating format (#p##) encodes both games played and rating: - First character: Games played in series (1-6) - Remaining: Injury rating (p70, p65, p60, p50, p40, p30, p20) """ + import math import random import discord @@ -40,11 +41,8 @@ class InjuryGroup(app_commands.Group): """Injury management command group with roll, set-new, and clear subcommands.""" def __init__(self): - super().__init__( - name="injury", - description="Injury management commands" - ) - self.logger = get_contextual_logger(f'{__name__}.InjuryGroup') + super().__init__(name="injury", description="Injury management commands") + self.logger = get_contextual_logger(f"{__name__}.InjuryGroup") self.logger.info("InjuryGroup initialized") def has_player_role(self, interaction: discord.Interaction) -> bool: @@ -53,13 +51,17 @@ class InjuryGroup(app_commands.Group): if not isinstance(interaction.user, discord.Member): return False + if interaction.guild is None: + return False + player_role = discord.utils.get( - interaction.guild.roles, - name=get_config().sba_players_role_name + interaction.guild.roles, name=get_config().sba_players_role_name ) return player_role in interaction.user.roles if player_role else False - @app_commands.command(name="roll", description="Roll for injury based on player's injury rating") + @app_commands.command( + name="roll", description="Roll for injury based on player's injury rating" + ) @app_commands.describe(player_name="Player name") @app_commands.autocomplete(player_name=player_autocomplete) @league_only() @@ -74,12 +76,14 @@ class InjuryGroup(app_commands.Group): raise BotException("Failed to get current season information") # Search for player using the search endpoint (more reliable than name param) - players = await player_service.search_players(player_name, limit=10, season=current.season) + players = await player_service.search_players( + player_name, limit=10, season=current.season + ) if not players: embed = EmbedTemplate.error( title="Player Not Found", - description=f"I did not find anybody named **{player_name}**." + description=f"I did not find anybody named **{player_name}**.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -89,14 +93,17 @@ class InjuryGroup(app_commands.Group): # Fetch full team data if team is not populated if player.team_id and not player.team: from services.team_service import team_service + player.team = await team_service.get_team(player.team_id) # Check if player already has an active injury - existing_injury = await injury_service.get_active_injury(player.id, current.season) + existing_injury = await injury_service.get_active_injury( + player.id, current.season + ) if existing_injury: embed = EmbedTemplate.error( title="Already Injured", - description=f"Hm. It looks like {player.name} is already hurt." + description=f"Hm. It looks like {player.name} is already hurt.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -105,7 +112,7 @@ class InjuryGroup(app_commands.Group): if not player.injury_rating: embed = EmbedTemplate.error( title="No Injury Rating", - description=f"{player.name} does not have an injury rating set." + description=f"{player.name} does not have an injury rating set.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -120,13 +127,13 @@ class InjuryGroup(app_commands.Group): raise ValueError("Games played must be between 1 and 6") # Validate rating format (should start with 'p') - if not injury_rating.startswith('p'): + if not injury_rating.startswith("p"): raise ValueError("Invalid rating format") except (ValueError, IndexError): embed = EmbedTemplate.error( title="Invalid Injury Rating Format", - description=f"{player.name} has an invalid injury rating: `{player.injury_rating}`\n\nExpected format: `#p##` (e.g., `1p70`, `4p50`)" + description=f"{player.name} has an invalid injury rating: `{player.injury_rating}`\n\nExpected format: `#p##` (e.g., `1p70`, `4p50`)", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -141,33 +148,25 @@ class InjuryGroup(app_commands.Group): injury_result = self._get_injury_result(injury_rating, games_played, roll_total) # Create response embed - embed = EmbedTemplate.warning( - title=f"Injury roll for {interaction.user.name}" - ) + embed = EmbedTemplate.warning(title=f"Injury roll for {interaction.user.name}") if player.team and player.team.thumbnail: embed.set_thumbnail(url=player.team.thumbnail) embed.add_field( name="Player", value=f"{player.name} ({player.primary_position})", - inline=True + inline=True, ) embed.add_field( - name="Injury Rating", - value=f"{player.injury_rating}", - inline=True + name="Injury Rating", value=f"{player.injury_rating}", inline=True ) # embed.add_field(name='', value='', inline=False) # Embed line break # Format dice roll in markdown (same format as /ab roll) dice_result = f"```md\n# {roll_total}\nDetails:[3d6 ({d1} {d2} {d3})]```" - embed.add_field( - name="Dice Roll", - value=dice_result, - inline=False - ) + embed.add_field(name="Dice Roll", value=dice_result, inline=False) view = None @@ -177,20 +176,20 @@ class InjuryGroup(app_commands.Group): embed.color = discord.Color.orange() if injury_result > 6: - gif_search_text = ['well shit', 'well fuck', 'god dammit'] + gif_search_text = ["well shit", "well fuck", "god dammit"] else: - gif_search_text = ['bummer', 'well damn'] + gif_search_text = ["bummer", "well damn"] if player.is_pitcher: - result_text += ' plus their current rest requirement' + result_text += " plus their current rest requirement" # Pitcher callback shows modal to collect rest games - async def pitcher_confirm_callback(button_interaction: discord.Interaction): + async def pitcher_confirm_callback( + button_interaction: discord.Interaction, + ): """Show modal to collect pitcher rest information.""" modal = PitcherRestModal( - player=player, - injury_games=injury_result, - season=current.season + player=player, injury_games=injury_result, season=current.season ) await button_interaction.response.send_modal(modal) @@ -198,12 +197,12 @@ class InjuryGroup(app_commands.Group): else: # Batter callback shows modal to collect current week/game - async def batter_confirm_callback(button_interaction: discord.Interaction): + async def batter_confirm_callback( + button_interaction: discord.Interaction, + ): """Show modal to collect current week/game information for batter injury.""" modal = BatterInjuryModal( - player=player, - injury_games=injury_result, - season=current.season + player=player, injury_games=injury_result, season=current.season ) await button_interaction.response.send_modal(modal) @@ -213,35 +212,31 @@ class InjuryGroup(app_commands.Group): # Only the player's team GM(s) can log the injury view = ConfirmationView( timeout=180.0, # 3 minutes for confirmation - responders=[player.team.gmid, player.team.gmid2] if player.team else None, + responders=( + [player.team.gmid, player.team.gmid2] if player.team else None + ), confirm_callback=injury_callback, confirm_label="Log Injury", - cancel_label="Ignore Injury" + cancel_label="Ignore Injury", ) - elif injury_result == 'REM': + elif injury_result == "REM": if player.is_pitcher: - result_text = '**FATIGUED**' + result_text = "**FATIGUED**" else: result_text = "**REMAINDER OF GAME**" embed.color = discord.Color.gold() - gif_search_text = ['this is fine', 'not even mad', 'could be worse'] + gif_search_text = ["this is fine", "not even mad", "could be worse"] else: # 'OK' result_text = "**No injury!**" embed.color = discord.Color.green() - gif_search_text = ['we are so back', 'all good', 'totally fine'] + gif_search_text = ["we are so back", "all good", "totally fine"] - embed.add_field( - name="Injury Length", - value=result_text, - inline=True - ) + embed.add_field(name="Injury Length", value=result_text, inline=True) try: - injury_gif = await GiphyService().get_gif( - phrase_options=gif_search_text - ) + injury_gif = await GiphyService().get_gif(phrase_options=gif_search_text) except Exception: - injury_gif = '' + injury_gif = "" embed.set_image(url=injury_gif) @@ -251,7 +246,6 @@ class InjuryGroup(app_commands.Group): else: await interaction.followup.send(embed=embed) - def _get_injury_result(self, rating: str, games_played: int, roll: int): """ Get injury result from the injury table. @@ -266,89 +260,194 @@ class InjuryGroup(app_commands.Group): """ # Injury table mapping inj_data = { - 'one': { - 'p70': ['OK', 'OK', 'OK', 'OK', 'OK', 'OK', 'REM', 'REM', 1, 1, 2, 2, 3, 3, 4, 4], - 'p65': [2, 2, 'OK', 'REM', 1, 2, 3, 3, 4, 4, 4, 4, 5, 6, 8, 12], - 'p60': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 4, 5, 5, 6, 8, 12, 16, 16], - 'p50': ['OK', 'REM', 1, 2, 3, 4, 4, 5, 5, 6, 8, 8, 12, 16, 16, 'OK'], - 'p40': ['OK', 1, 2, 3, 4, 4, 5, 6, 6, 8, 8, 12, 16, 24, 'REM', 'OK'], - 'p30': ['OK', 4, 1, 3, 4, 5, 6, 8, 8, 12, 16, 24, 4, 2, 'REM', 'OK'], - 'p20': ['OK', 1, 2, 4, 5, 8, 8, 24, 16, 12, 12, 6, 4, 3, 'REM', 'OK'] + "one": { + "p70": [ + "OK", + "OK", + "OK", + "OK", + "OK", + "OK", + "REM", + "REM", + 1, + 1, + 2, + 2, + 3, + 3, + 4, + 4, + ], + "p65": [2, 2, "OK", "REM", 1, 2, 3, 3, 4, 4, 4, 4, 5, 6, 8, 12], + "p60": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 4, 5, 5, 6, 8, 12, 16, 16], + "p50": ["OK", "REM", 1, 2, 3, 4, 4, 5, 5, 6, 8, 8, 12, 16, 16, "OK"], + "p40": ["OK", 1, 2, 3, 4, 4, 5, 6, 6, 8, 8, 12, 16, 24, "REM", "OK"], + "p30": ["OK", 4, 1, 3, 4, 5, 6, 8, 8, 12, 16, 24, 4, 2, "REM", "OK"], + "p20": ["OK", 1, 2, 4, 5, 8, 8, 24, 16, 12, 12, 6, 4, 3, "REM", "OK"], }, - 'two': { - 'p70': [4, 3, 2, 2, 1, 1, 'REM', 'OK', 'REM', 'OK', 2, 1, 2, 2, 3, 4], - 'p65': [8, 5, 4, 2, 2, 'OK', 1, 'OK', 'REM', 1, 'REM', 2, 3, 4, 6, 12], - 'p60': [1, 3, 4, 5, 2, 2, 'OK', 1, 3, 'REM', 4, 4, 6, 8, 12, 3], - 'p50': [4, 'OK', 'OK', 'REM', 1, 2, 4, 3, 4, 5, 4, 6, 8, 12, 12, 'OK'], - 'p40': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 5, 4, 6, 8, 12, 16, 16, 'OK'], - 'p30': ['OK', 'REM', 1, 2, 3, 4, 4, 5, 6, 5, 8, 12, 16, 24, 'REM', 'OK'], - 'p20': ['OK', 1, 4, 4, 5, 5, 6, 6, 12, 8, 16, 24, 8, 3, 2, 'REM'] + "two": { + "p70": [4, 3, 2, 2, 1, 1, "REM", "OK", "REM", "OK", 2, 1, 2, 2, 3, 4], + "p65": [8, 5, 4, 2, 2, "OK", 1, "OK", "REM", 1, "REM", 2, 3, 4, 6, 12], + "p60": [1, 3, 4, 5, 2, 2, "OK", 1, 3, "REM", 4, 4, 6, 8, 12, 3], + "p50": [4, "OK", "OK", "REM", 1, 2, 4, 3, 4, 5, 4, 6, 8, 12, 12, "OK"], + "p40": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 5, 4, 6, 8, 12, 16, 16, "OK"], + "p30": [ + "OK", + "REM", + 1, + 2, + 3, + 4, + 4, + 5, + 6, + 5, + 8, + 12, + 16, + 24, + "REM", + "OK", + ], + "p20": ["OK", 1, 4, 4, 5, 5, 6, 6, 12, 8, 16, 24, 8, 3, 2, "REM"], }, - 'three': { - 'p70': [], - 'p65': ['OK', 'OK', 'REM', 1, 3, 'OK', 'REM', 1, 2, 1, 2, 3, 4, 4, 5, 'REM'], - 'p60': ['OK', 5, 'OK', 'REM', 1, 2, 2, 3, 4, 4, 1, 3, 5, 6, 8, 'REM'], - 'p50': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, 'REM'], - 'p40': ['OK', 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, 'REM'], - 'p30': ['OK', 1, 2, 3, 4, 5, 4, 6, 5, 6, 8, 8, 12, 16, 1, 'REM'], - 'p20': ['OK', 1, 2, 4, 4, 8, 8, 6, 5, 12, 6, 16, 24, 3, 4, 'REM'] + "three": { + "p70": [], + "p65": [ + "OK", + "OK", + "REM", + 1, + 3, + "OK", + "REM", + 1, + 2, + 1, + 2, + 3, + 4, + 4, + 5, + "REM", + ], + "p60": ["OK", 5, "OK", "REM", 1, 2, 2, 3, 4, 4, 1, 3, 5, 6, 8, "REM"], + "p50": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, "REM"], + "p40": ["OK", 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, "REM"], + "p30": ["OK", 1, 2, 3, 4, 5, 4, 6, 5, 6, 8, 8, 12, 16, 1, "REM"], + "p20": ["OK", 1, 2, 4, 4, 8, 8, 6, 5, 12, 6, 16, 24, 3, 4, "REM"], }, - 'four': { - 'p70': [], - 'p65': [], - 'p60': ['OK', 'OK', 'REM', 3, 3, 'OK', 'REM', 1, 2, 1, 4, 4, 5, 6, 8, 'REM'], - 'p50': ['OK', 6, 4, 'OK', 'REM', 1, 2, 4, 4, 3, 5, 3, 6, 8, 12, 'REM'], - 'p40': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, 'REM'], - 'p30': ['OK', 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, 'REM'], - 'p20': ['OK', 1, 2, 3, 4, 5, 4, 6, 5, 6, 12, 8, 8, 16, 1, 'REM'] + "four": { + "p70": [], + "p65": [], + "p60": [ + "OK", + "OK", + "REM", + 3, + 3, + "OK", + "REM", + 1, + 2, + 1, + 4, + 4, + 5, + 6, + 8, + "REM", + ], + "p50": ["OK", 6, 4, "OK", "REM", 1, 2, 4, 4, 3, 5, 3, 6, 8, 12, "REM"], + "p40": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, "REM"], + "p30": ["OK", 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, "REM"], + "p20": ["OK", 1, 2, 3, 4, 5, 4, 6, 5, 6, 12, 8, 8, 16, 1, "REM"], }, - 'five': { - 'p70': [], - 'p65': [], - 'p60': ['OK', 'REM', 'REM', 'REM', 3, 'OK', 1, 'REM', 2, 1, 'OK', 4, 5, 2, 6, 8], - 'p50': ['OK', 'OK', 'REM', 1, 1, 'OK', 'REM', 3, 2, 4, 4, 5, 5, 6, 8, 12], - 'p40': ['OK', 6, 6, 'OK', 1, 3, 2, 4, 4, 5, 'REM', 3, 8, 6, 12, 1], - 'p30': ['OK', 'OK', 'REM', 4, 1, 2, 5, 4, 6, 3, 4, 8, 5, 6, 12, 'REM'], - 'p20': ['OK', 'REM', 2, 3, 4, 4, 5, 4, 6, 5, 8, 6, 8, 1, 12, 'REM'] + "five": { + "p70": [], + "p65": [], + "p60": [ + "OK", + "REM", + "REM", + "REM", + 3, + "OK", + 1, + "REM", + 2, + 1, + "OK", + 4, + 5, + 2, + 6, + 8, + ], + "p50": [ + "OK", + "OK", + "REM", + 1, + 1, + "OK", + "REM", + 3, + 2, + 4, + 4, + 5, + 5, + 6, + 8, + 12, + ], + "p40": ["OK", 6, 6, "OK", 1, 3, 2, 4, 4, 5, "REM", 3, 8, 6, 12, 1], + "p30": ["OK", "OK", "REM", 4, 1, 2, 5, 4, 6, 3, 4, 8, 5, 6, 12, "REM"], + "p20": ["OK", "REM", 2, 3, 4, 4, 5, 4, 6, 5, 8, 6, 8, 1, 12, "REM"], + }, + "six": { + "p70": [], + "p65": [], + "p60": [], + "p50": [], + "p40": ["OK", 6, 6, "OK", 1, 3, 2, 4, 4, 5, "REM", 3, 8, 6, 1, 12], + "p30": ["OK", "OK", "REM", 5, 1, 3, 6, 4, 5, 2, 4, 8, 3, 5, 12, "REM"], + "p20": ["OK", "REM", 4, 6, 2, 3, 6, 4, 8, 5, 5, 6, 3, 1, 12, "REM"], }, - 'six': { - 'p70': [], - 'p65': [], - 'p60': [], - 'p50': [], - 'p40': ['OK', 6, 6, 'OK', 1, 3, 2, 4, 4, 5, 'REM', 3, 8, 6, 1, 12], - 'p30': ['OK', 'OK', 'REM', 5, 1, 3, 6, 4, 5, 2, 4, 8, 3, 5, 12, 'REM'], - 'p20': ['OK', 'REM', 4, 6, 2, 3, 6, 4, 8, 5, 5, 6, 3, 1, 12, 'REM'] - } } # Map games_played to key - games_map = {1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six'} + games_map = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six"} games_key = games_map.get(games_played) if not games_key: - return 'OK' + return "OK" # Get the injury table for this rating and games played injury_table = inj_data.get(games_key, {}).get(rating, []) # If no table exists (e.g., p70 with 3+ games), no injury if not injury_table: - return 'OK' + return "OK" # Get result from table (roll 3-18 maps to index 0-15) table_index = roll - 3 if 0 <= table_index < len(injury_table): return injury_table[table_index] - return 'OK' + return "OK" - @app_commands.command(name="set-new", description="Set a new injury for a player (requires SBA Players role)") + @app_commands.command( + name="set-new", + description="Set a new injury for a player (requires SBA Players role)", + ) @app_commands.describe( player_name="Player name to injure", this_week="Current week number", this_game="Current game number (1-4)", - injury_games="Number of games player will be out" + injury_games="Number of games player will be out", ) @league_only() @logged_command("/injury set-new") @@ -358,14 +457,14 @@ class InjuryGroup(app_commands.Group): player_name: str, this_week: int, this_game: int, - injury_games: int + injury_games: int, ): """Set a new injury for a player on your team.""" # Check role permissions if not self.has_player_role(interaction): embed = EmbedTemplate.error( title="Permission Denied", - description=f"This command requires the **{get_config().sba_players_role_name}** role." + description=f"This command requires the **{get_config().sba_players_role_name}** role.", ) await interaction.response.send_message(embed=embed, ephemeral=True) return @@ -376,7 +475,7 @@ class InjuryGroup(app_commands.Group): if this_game < 1 or this_game > 4: embed = EmbedTemplate.error( title="Invalid Input", - description="Game number must be between 1 and 4." + description="Game number must be between 1 and 4.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -384,7 +483,7 @@ class InjuryGroup(app_commands.Group): if injury_games < 1: embed = EmbedTemplate.error( title="Invalid Input", - description="Injury duration must be at least 1 game." + description="Injury duration must be at least 1 game.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -395,12 +494,14 @@ class InjuryGroup(app_commands.Group): raise BotException("Failed to get current season information") # Search for player using the search endpoint (more reliable than name param) - players = await player_service.search_players(player_name, limit=10, season=current.season) + players = await player_service.search_players( + player_name, limit=10, season=current.season + ) if not players: embed = EmbedTemplate.error( title="Player Not Found", - description=f"I did not find anybody named **{player_name}**." + description=f"I did not find anybody named **{player_name}**.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -410,6 +511,7 @@ class InjuryGroup(app_commands.Group): # Fetch full team data if team is not populated if player.team_id and not player.team: from services.team_service import team_service + player.team = await team_service.get_team(player.team_id) # Check if player is on user's team @@ -418,7 +520,9 @@ class InjuryGroup(app_commands.Group): # TODO: Add team ownership verification # Check if player already has an active injury - existing_injury = await injury_service.get_active_injury(player.id, current.season) + existing_injury = await injury_service.get_active_injury( + player.id, current.season + ) # Data consistency check: If injury exists but il_return is None, it's stale data if existing_injury: @@ -431,12 +535,14 @@ class InjuryGroup(app_commands.Group): await injury_service.clear_injury(existing_injury.id) # Notify user but allow them to proceed - self.logger.info(f"Cleared stale injury {existing_injury.id} for player {player.id}") + self.logger.info( + f"Cleared stale injury {existing_injury.id} for player {player.id}" + ) else: # Valid active injury - player is actually injured embed = EmbedTemplate.error( title="Already Injured", - description=f"Hm. It looks like {player.name} is already hurt (returns {player.il_return})." + description=f"Hm. It looks like {player.name} is already hurt (returns {player.il_return}).", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -456,7 +562,7 @@ class InjuryGroup(app_commands.Group): start_week = this_week if this_game != 4 else this_week + 1 start_game = this_game + 1 if this_game != 4 else 1 - return_date = f'w{return_week:02d}g{return_game}' + return_date = f"w{return_week:02d}g{return_game}" # Create injury record injury = await injury_service.create_injury( @@ -466,49 +572,43 @@ class InjuryGroup(app_commands.Group): start_week=start_week, start_game=start_game, end_week=return_week, - end_game=return_game + end_game=return_game, ) if not injury: embed = EmbedTemplate.error( title="Error", - description="Well that didn't work. Failed to create injury record." + description="Well that didn't work. Failed to create injury record.", ) await interaction.followup.send(embed=embed, ephemeral=True) return # Update player's il_return field - await player_service.update_player(player.id, {'il_return': return_date}) + await player_service.update_player(player.id, {"il_return": return_date}) # Success response embed = EmbedTemplate.success( title="Injury Recorded", - description=f"{player.name}'s injury has been logged" + description=f"{player.name}'s injury has been logged", ) embed.add_field( - name="Player", - value=f"{player.name} ({player.pos_1})", - inline=True + name="Player", value=f"{player.name} ({player.pos_1})", inline=True ) embed.add_field( name="Duration", value=f"{injury_games} game{'s' if injury_games > 1 else ''}", - inline=True + inline=True, ) - embed.add_field( - name="Return Date", - value=return_date, - inline=True - ) + embed.add_field(name="Return Date", value=return_date, inline=True) if player.team: embed.add_field( name="Team", value=f"{player.team.lname} ({player.team.abbrev})", - inline=False + inline=False, ) await interaction.followup.send(embed=embed) @@ -518,10 +618,12 @@ class InjuryGroup(app_commands.Group): f"Injury set for {player.name}: {injury_games} games, returns {return_date}", player_id=player.id, season=current.season, - injury_id=injury.id + injury_id=injury.id, ) - - def _calc_injury_dates(self, start_week: int, start_game: int, injury_games: int) -> dict: + + def _calc_injury_dates( + self, start_week: int, start_game: int, injury_games: int + ) -> dict: """ Calculate injury dates from start week/game and injury duration. @@ -549,15 +651,16 @@ class InjuryGroup(app_commands.Group): actual_start_game = start_game + 1 if start_game != 4 else 1 return { - 'total_games': injury_games, - 'start_week': actual_start_week, - 'start_game': actual_start_game, - 'end_week': return_week, - 'end_game': return_game + "total_games": injury_games, + "start_week": actual_start_week, + "start_game": actual_start_game, + "end_week": return_week, + "end_game": return_game, } - - @app_commands.command(name="clear", description="Clear a player's injury (requires SBA Players role)") + @app_commands.command( + name="clear", description="Clear a player's injury (requires SBA Players role)" + ) @app_commands.describe(player_name="Player name to clear injury") @app_commands.autocomplete(player_name=player_autocomplete) @league_only() @@ -568,7 +671,7 @@ class InjuryGroup(app_commands.Group): if not self.has_player_role(interaction): embed = EmbedTemplate.error( title="Permission Denied", - description=f"This command requires the **{get_config().sba_players_role_name}** role." + description=f"This command requires the **{get_config().sba_players_role_name}** role.", ) await interaction.response.send_message(embed=embed, ephemeral=True) return @@ -581,12 +684,14 @@ class InjuryGroup(app_commands.Group): raise BotException("Failed to get current season information") # Search for player using the search endpoint (more reliable than name param) - players = await player_service.search_players(player_name, limit=10, season=current.season) + players = await player_service.search_players( + player_name, limit=10, season=current.season + ) if not players: embed = EmbedTemplate.error( title="Player Not Found", - description=f"I did not find anybody named **{player_name}**." + description=f"I did not find anybody named **{player_name}**.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -596,6 +701,7 @@ class InjuryGroup(app_commands.Group): # Fetch full team data if team is not populated if player.team_id and not player.team: from services.team_service import team_service + player.team = await team_service.get_team(player.team_id) # Get active injury @@ -603,8 +709,7 @@ class InjuryGroup(app_commands.Group): if not injury: embed = EmbedTemplate.error( - title="No Active Injury", - description=f"{player.name} isn't injured." + title="No Active Injury", description=f"{player.name} isn't injured." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -612,7 +717,7 @@ class InjuryGroup(app_commands.Group): # Create confirmation embed embed = EmbedTemplate.info( title=f"{player.name}", - description=f"Is **{player.name}** cleared to return?" + description=f"Is **{player.name}** cleared to return?", ) if player.team and player.team.thumbnail is not None: @@ -621,33 +726,27 @@ class InjuryGroup(app_commands.Group): embed.add_field( name="Player", value=f"{player.name} ({player.primary_position})", - inline=True + inline=True, ) if player.team: embed.add_field( name="Team", value=f"{player.team.lname} ({player.team.abbrev})", - inline=True + inline=True, ) - embed.add_field( - name="Expected Return", - value=injury.return_date, - inline=True - ) + embed.add_field(name="Expected Return", value=injury.return_date, inline=True) - embed.add_field( - name="Games Missed", - value=injury.duration_display, - inline=True - ) + embed.add_field(name="Games Missed", value=injury.duration_display, inline=True) # Initialize responder_team to None for major league teams if player.team.roster_type() == RosterType.MAJOR_LEAGUE: responder_team = player.team else: - responder_team = await team_utils.get_user_major_league_team(interaction.user.id) + responder_team = await team_utils.get_user_major_league_team( + interaction.user.id + ) # Create callback for confirmation async def clear_confirm_callback(button_interaction: discord.Interaction): @@ -658,37 +757,33 @@ class InjuryGroup(app_commands.Group): if not success: error_embed = EmbedTemplate.error( title="Error", - description="Failed to clear the injury. Please try again." + description="Failed to clear the injury. Please try again.", + ) + await button_interaction.response.send_message( + embed=error_embed, ephemeral=True ) - await button_interaction.response.send_message(embed=error_embed, ephemeral=True) return # Clear player's il_return field - await player_service.update_player(player.id, {'il_return': ''}) + await player_service.update_player(player.id, {"il_return": ""}) # Success response success_embed = EmbedTemplate.success( title="Injury Cleared", - description=f"{player.name} has been cleared and is eligible to play again." + description=f"{player.name} has been cleared and is eligible to play again.", ) success_embed.add_field( - name="Injury Return Date", - value=injury.return_date, - inline=True + name="Injury Return Date", value=injury.return_date, inline=True ) success_embed.add_field( - name="Total Games Missed", - value=injury.duration_display, - inline=True + name="Total Games Missed", value=injury.duration_display, inline=True ) if player.team: success_embed.add_field( - name="Team", - value=f"{player.team.lname}", - inline=False + name="Team", value=f"{player.team.lname}", inline=False ) if player.team.thumbnail is not None: success_embed.set_thumbnail(url=player.team.thumbnail) @@ -700,17 +795,19 @@ class InjuryGroup(app_commands.Group): f"Injury cleared for {player.name}", player_id=player.id, season=current.season, - injury_id=injury.id + injury_id=injury.id, ) # Create confirmation view view = ConfirmationView( user_id=interaction.user.id, timeout=180.0, # 3 minutes for confirmation - responders=[responder_team.gmid, responder_team.gmid2] if responder_team else None, + responders=( + [responder_team.gmid, responder_team.gmid2] if responder_team else None + ), confirm_callback=clear_confirm_callback, confirm_label="Clear Injury", - cancel_label="Cancel" + cancel_label="Cancel", ) # Send confirmation embed with view diff --git a/commands/league/submit_scorecard.py b/commands/league/submit_scorecard.py index e70c895..fca00ab 100644 --- a/commands/league/submit_scorecard.py +++ b/commands/league/submit_scorecard.py @@ -175,14 +175,14 @@ class SubmitScorecardCommands(commands.Cog): # Delete old data try: await play_service.delete_plays_for_game(duplicate_game.id) - except: + except Exception: pass # May not exist try: await decision_service.delete_decisions_for_game( duplicate_game.id ) - except: + except Exception: pass # May not exist await game_service.wipe_game_data(duplicate_game.id) @@ -354,7 +354,7 @@ class SubmitScorecardCommands(commands.Cog): try: await standings_service.recalculate_standings(current.season) - except: + except Exception: # Non-critical error self.logger.error("Failed to recalculate standings") @@ -372,11 +372,11 @@ class SubmitScorecardCommands(commands.Cog): await play_service.delete_plays_for_game(game_id) elif rollback_state == "PLAYS_POSTED": await play_service.delete_plays_for_game(game_id) - except: + except Exception: pass # Best effort rollback await interaction.edit_original_response( - content=f"❌ An unexpected error occurred: {str(e)}" + content="❌ An unexpected error occurred. Please try again or contact an admin." ) def _match_manager(self, team: Team, manager_name: str): diff --git a/config.py b/config.py index 98f439f..3964a7d 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,7 @@ """ Configuration management for Discord Bot v2.0 """ + import os from typing import Optional @@ -40,17 +41,18 @@ class BotConfig(BaseSettings): playoff_round_two_games: int = 7 playoff_round_three_games: int = 7 modern_stats_start_season: int = 8 - offseason_flag: bool = False # When True, relaxes roster limits and disables weekly freeze/thaw + offseason_flag: bool = ( + False # When True, relaxes roster limits and disables weekly freeze/thaw + ) # Roster Limits - expand_mil_week: int = 15 # Week when MiL roster expands (early vs late limits) - ml_roster_limit_early: int = 26 # ML limit for weeks before expand_mil_week - ml_roster_limit_late: int = 26 # ML limit for weeks >= expand_mil_week - mil_roster_limit_early: int = 6 # MiL limit for weeks before expand_mil_week - mil_roster_limit_late: int = 14 # MiL limit for weeks >= expand_mil_week + expand_mil_week: int = 15 # Week when MiL roster expands (early vs late limits) + ml_roster_limit_early: int = 26 # ML limit for weeks before expand_mil_week + ml_roster_limit_late: int = 26 # ML limit for weeks >= expand_mil_week + mil_roster_limit_early: int = 6 # MiL limit for weeks before expand_mil_week + mil_roster_limit_late: int = 14 # MiL limit for weeks >= expand_mil_week ml_roster_limit_offseason: int = 69 # ML limit during offseason - mil_roster_limit_offseason: int = 69 # MiL limit during offseason - + mil_roster_limit_offseason: int = 69 # MiL limit during offseason # API Constants api_version: str = "v3" @@ -60,10 +62,10 @@ class BotConfig(BaseSettings): # Draft Constants default_pick_minutes: int = 10 draft_rounds: int = 32 - draft_team_count: int = 16 # Number of teams in draft - draft_linear_rounds: int = 10 # Rounds 1-10 are linear, 11+ are snake - swar_cap_limit: float = 32.00 # Maximum sWAR cap for team roster - cap_player_count: int = 26 # Number of players that count toward cap + draft_team_count: int = 16 # Number of teams in draft + draft_linear_rounds: int = 10 # Rounds 1-10 are linear, 11+ are snake + swar_cap_limit: float = 32.00 # Maximum sWAR cap for team roster + cap_player_count: int = 26 # Number of players that count toward cap # Special Team IDs free_agent_team_id: int = 547 @@ -80,7 +82,7 @@ class BotConfig(BaseSettings): # Base URLs sba_base_url: str = "https://sba.manticorum.com" - sba_logo_url: str = f'{sba_base_url}/images/sba-logo.png' + sba_logo_url: str = f"{sba_base_url}/images/sba-logo.png" # Application settings log_level: str = "INFO" @@ -92,29 +94,33 @@ class BotConfig(BaseSettings): # Draft Sheet settings (for writing picks to Google Sheets) # Sheet IDs can be overridden via environment variables: DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc. - draft_sheet_enabled: bool = True # Feature flag - set DRAFT_SHEET_ENABLED=false to disable + draft_sheet_enabled: bool = ( + True # Feature flag - set DRAFT_SHEET_ENABLED=false to disable + ) draft_sheet_worksheet: str = "Ordered List" # Worksheet name to write picks to - draft_sheet_start_column: str = "D" # Column where pick data starts (D, E, F, G for 4 columns) + draft_sheet_start_column: str = ( + "D" # Column where pick data starts (D, E, F, G for 4 columns) + ) # Giphy API settings - giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD" + giphy_api_key: str = "" giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate" # Optional Redis caching settings redis_url: str = "" # Empty string means no Redis caching redis_cache_ttl: int = 300 # 5 minutes default TTL - + model_config = SettingsConfigDict( env_file=".env", case_sensitive=False, - extra="ignore" # Ignore extra environment variables + extra="ignore", # Ignore extra environment variables ) - + @property def is_development(self) -> bool: """Check if running in development mode.""" return self.environment.lower() == "development" - + @property def is_testing(self) -> bool: """Check if running in test mode.""" @@ -139,7 +145,7 @@ class BotConfig(BaseSettings): # Default sheet IDs (hardcoded as fallback) default_keys = { 12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU", - 13: "1vWJfvuz9jN5BU2ZR0X0oC9BAVr_R8o-dWZsF2KXQMsE" + 13: "1vWJfvuz9jN5BU2ZR0X0oC9BAVr_R8o-dWZsF2KXQMsE", } # Check environment variable first (allows runtime override) @@ -165,9 +171,10 @@ class BotConfig(BaseSettings): # Global configuration instance - lazily initialized to avoid import-time errors _config = None + def get_config() -> BotConfig: """Get the global configuration instance.""" global _config if _config is None: _config = BotConfig() # type: ignore - return _config \ No newline at end of file + return _config diff --git a/services/giphy_service.py b/services/giphy_service.py index 6e5d0a1..165d5c3 100644 --- a/services/giphy_service.py +++ b/services/giphy_service.py @@ -4,93 +4,88 @@ Giphy Service for Discord Bot v2.0 Provides async interface to Giphy API with disappointment-based search phrases. Used for Easter egg features like the soak command. """ + import random from typing import List, Optional +from urllib.parse import quote import aiohttp from utils.logging import get_contextual_logger from config import get_config from exceptions import APIException - # Disappointment tier configuration DISAPPOINTMENT_TIERS = { - 'tier_1': { - 'max_seconds': 1800, # 30 minutes - 'phrases': [ + "tier_1": { + "max_seconds": 1800, # 30 minutes + "phrases": [ "extremely disappointed", "so disappointed", "are you kidding me", "seriously", - "unbelievable" + "unbelievable", ], - 'description': "Maximum Disappointment" + "description": "Maximum Disappointment", }, - 'tier_2': { - 'max_seconds': 7200, # 2 hours - 'phrases': [ + "tier_2": { + "max_seconds": 7200, # 2 hours + "phrases": [ "very disappointed", "can't believe you", "not happy", "shame on you", - "facepalm" + "facepalm", ], - 'description': "Severe Disappointment" + "description": "Severe Disappointment", }, - 'tier_3': { - 'max_seconds': 21600, # 6 hours - 'phrases': [ + "tier_3": { + "max_seconds": 21600, # 6 hours + "phrases": [ "disappointed", "not impressed", "shaking head", "eye roll", - "really" + "really", ], - 'description': "Strong Disappointment" + "description": "Strong Disappointment", }, - 'tier_4': { - 'max_seconds': 86400, # 24 hours - 'phrases': [ + "tier_4": { + "max_seconds": 86400, # 24 hours + "phrases": [ "mildly disappointed", "not great", "could be better", "sigh", - "seriously" + "seriously", ], - 'description': "Moderate Disappointment" + "description": "Moderate Disappointment", }, - 'tier_5': { - 'max_seconds': 604800, # 7 days - 'phrases': [ - "slightly disappointed", - "oh well", - "shrug", - "meh", - "not bad" - ], - 'description': "Mild Disappointment" + "tier_5": { + "max_seconds": 604800, # 7 days + "phrases": ["slightly disappointed", "oh well", "shrug", "meh", "not bad"], + "description": "Mild Disappointment", }, - 'tier_6': { - 'max_seconds': float('inf'), # 7+ days - 'phrases': [ + "tier_6": { + "max_seconds": float("inf"), # 7+ days + "phrases": [ "not disappointed", "relieved", "proud", "been worse", - "fine i guess" + "fine i guess", ], - 'description': "Minimal Disappointment" + "description": "Minimal Disappointment", }, - 'first_ever': { - 'phrases': [ + "first_ever": { + "phrases": [ "here we go", "oh boy", "uh oh", "getting started", - "and so it begins" + "and so it begins", ], - 'description': "The Beginning" - } + "description": "The Beginning", + }, } @@ -102,7 +97,7 @@ class GiphyService: self.config = get_config() self.api_key = self.config.giphy_api_key self.translate_url = self.config.giphy_translate_url - self.logger = get_contextual_logger(f'{__name__}.GiphyService') + self.logger = get_contextual_logger(f"{__name__}.GiphyService") def get_tier_for_seconds(self, seconds_elapsed: Optional[int]) -> str: """ @@ -115,13 +110,13 @@ class GiphyService: Tier key string (e.g., 'tier_1', 'first_ever') """ if seconds_elapsed is None: - return 'first_ever' + return "first_ever" - for tier_key in ['tier_1', 'tier_2', 'tier_3', 'tier_4', 'tier_5', 'tier_6']: - if seconds_elapsed <= DISAPPOINTMENT_TIERS[tier_key]['max_seconds']: + for tier_key in ["tier_1", "tier_2", "tier_3", "tier_4", "tier_5", "tier_6"]: + if seconds_elapsed <= DISAPPOINTMENT_TIERS[tier_key]["max_seconds"]: return tier_key - return 'tier_6' # Fallback to lowest disappointment + return "tier_6" # Fallback to lowest disappointment def get_random_phrase_for_tier(self, tier_key: str) -> str: """ @@ -139,7 +134,7 @@ class GiphyService: if tier_key not in DISAPPOINTMENT_TIERS: raise ValueError(f"Invalid tier key: {tier_key}") - phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases'] + phrases = DISAPPOINTMENT_TIERS[tier_key]["phrases"] return random.choice(phrases) def get_tier_description(self, tier_key: str) -> str: @@ -158,7 +153,7 @@ class GiphyService: if tier_key not in DISAPPOINTMENT_TIERS: raise ValueError(f"Invalid tier key: {tier_key}") - return DISAPPOINTMENT_TIERS[tier_key]['description'] + return DISAPPOINTMENT_TIERS[tier_key]["description"] async def get_disappointment_gif(self, tier_key: str) -> str: """ @@ -181,7 +176,7 @@ class GiphyService: if tier_key not in DISAPPOINTMENT_TIERS: raise ValueError(f"Invalid tier key: {tier_key}") - phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases'] + phrases = DISAPPOINTMENT_TIERS[tier_key]["phrases"] # Shuffle phrases for variety and retry capability shuffled_phrases = random.sample(phrases, len(phrases)) @@ -189,39 +184,61 @@ class GiphyService: async with aiohttp.ClientSession() as session: for phrase in shuffled_phrases: try: - url = f"{self.translate_url}?s={phrase}&api_key={self.api_key}" + url = f"{self.translate_url}?s={quote(phrase)}&api_key={quote(self.api_key)}" - async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + async with session.get( + url, timeout=aiohttp.ClientTimeout(total=5) + ) as resp: if resp.status == 200: data = await resp.json() # Filter out Trump GIFs (legacy behavior) - gif_title = data.get('data', {}).get('title', '').lower() - if 'trump' in gif_title: - self.logger.debug(f"Filtered out Trump GIF for phrase: {phrase}") + gif_title = data.get("data", {}).get("title", "").lower() + if "trump" in gif_title: + self.logger.debug( + f"Filtered out Trump GIF for phrase: {phrase}" + ) continue # Get the actual GIF image URL, not the web page URL - gif_url = data.get('data', {}).get('images', {}).get('original', {}).get('url') + gif_url = ( + data.get("data", {}) + .get("images", {}) + .get("original", {}) + .get("url") + ) if gif_url: - self.logger.info(f"Successfully fetched GIF for phrase: {phrase}", gif_url=gif_url) + self.logger.info( + f"Successfully fetched GIF for phrase: {phrase}", + gif_url=gif_url, + ) return gif_url else: - self.logger.warning(f"No GIF URL in response for phrase: {phrase}") + self.logger.warning( + f"No GIF URL in response for phrase: {phrase}" + ) else: - self.logger.warning(f"Giphy API returned status {resp.status} for phrase: {phrase}") + self.logger.warning( + f"Giphy API returned status {resp.status} for phrase: {phrase}" + ) except aiohttp.ClientError as e: - self.logger.error(f"HTTP error fetching GIF for phrase '{phrase}': {e}") + self.logger.error( + f"HTTP error fetching GIF for phrase '{phrase}': {e}" + ) except Exception as e: - self.logger.error(f"Unexpected error fetching GIF for phrase '{phrase}': {e}") + self.logger.error( + f"Unexpected error fetching GIF for phrase '{phrase}': {e}" + ) # All phrases failed error_msg = f"Failed to fetch any GIF for tier: {tier_key}" self.logger.error(error_msg) raise APIException(error_msg) - - async def get_gif(self, phrase: Optional[str] = None, phrase_options: Optional[List[str]] = None) -> str: + + async def get_gif( + self, phrase: Optional[str] = None, phrase_options: Optional[List[str]] = None + ) -> str: """ Fetch a GIF from Giphy based on a phrase or list of phrase options. @@ -237,9 +254,11 @@ class GiphyService: APIException: If all GIF fetch attempts fail """ if phrase is None and phrase_options is None: - raise ValueError('To get a gif, one of `phrase` or `phrase_options` must be provided') + raise ValueError( + "To get a gif, one of `phrase` or `phrase_options` must be provided" + ) - search_phrase = 'send help' + search_phrase = "send help" if phrase is not None: search_phrase = phrase elif phrase_options is not None: @@ -250,33 +269,53 @@ class GiphyService: while attempts < 3: attempts += 1 try: - url = f"{self.translate_url}?s={search_phrase}&api_key={self.api_key}" + url = f"{self.translate_url}?s={quote(search_phrase)}&api_key={quote(self.api_key)}" - async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp: + async with session.get( + url, timeout=aiohttp.ClientTimeout(total=3) + ) as resp: if resp.status != 200: - self.logger.warning(f"Giphy API returned status {resp.status} for phrase: {search_phrase}") + self.logger.warning( + f"Giphy API returned status {resp.status} for phrase: {search_phrase}" + ) continue data = await resp.json() # Filter out Trump GIFs (legacy behavior) - gif_title = data.get('data', {}).get('title', '').lower() - if 'trump' in gif_title: - self.logger.debug(f"Filtered out Trump GIF for phrase: {search_phrase}") + gif_title = data.get("data", {}).get("title", "").lower() + if "trump" in gif_title: + self.logger.debug( + f"Filtered out Trump GIF for phrase: {search_phrase}" + ) continue # Get the actual GIF image URL, not the web page URL - gif_url = data.get('data', {}).get('images', {}).get('original', {}).get('url') + gif_url = ( + data.get("data", {}) + .get("images", {}) + .get("original", {}) + .get("url") + ) if gif_url: - self.logger.info(f"Successfully fetched GIF for phrase: {search_phrase}", gif_url=gif_url) + self.logger.info( + f"Successfully fetched GIF for phrase: {search_phrase}", + gif_url=gif_url, + ) return gif_url else: - self.logger.warning(f"No GIF URL in response for phrase: {search_phrase}") + self.logger.warning( + f"No GIF URL in response for phrase: {search_phrase}" + ) except aiohttp.ClientError as e: - self.logger.error(f"HTTP error fetching GIF for phrase '{search_phrase}': {e}") + self.logger.error( + f"HTTP error fetching GIF for phrase '{search_phrase}': {e}" + ) except Exception as e: - self.logger.error(f"Unexpected error fetching GIF for phrase '{search_phrase}': {e}") + self.logger.error( + f"Unexpected error fetching GIF for phrase '{search_phrase}': {e}" + ) # All attempts failed error_msg = f"Failed to fetch any GIF for phrase: {search_phrase}" diff --git a/tasks/transaction_freeze.py b/tasks/transaction_freeze.py index fee9ecd..09af9cf 100644 --- a/tasks/transaction_freeze.py +++ b/tasks/transaction_freeze.py @@ -4,6 +4,7 @@ Transaction Freeze/Thaw Task for Discord Bot v2.0 Automated weekly system for freezing and processing transactions. Runs on a schedule to increment weeks and process contested transactions. """ + import asyncio import random from datetime import datetime, UTC @@ -30,6 +31,7 @@ class TransactionPriority: Data class for transaction priority calculation. Used to resolve contested transactions (multiple teams wanting same player). """ + transaction: Transaction team_win_percentage: float tiebreaker: float # win% + small random number for randomized tiebreak @@ -42,6 +44,7 @@ class TransactionPriority: @dataclass class ConflictContender: """A team contending for a contested player.""" + team_abbrev: str wins: int losses: int @@ -52,6 +55,7 @@ class ConflictContender: @dataclass class ConflictResolution: """Details of a conflict resolution for a contested player.""" + player_name: str player_swar: float contenders: List[ConflictContender] @@ -62,6 +66,7 @@ class ConflictResolution: @dataclass class ThawedMove: """A move that was successfully thawed (unfrozen).""" + move_id: str team_abbrev: str players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team) @@ -71,6 +76,7 @@ class ThawedMove: @dataclass class CancelledMove: """A move that was cancelled due to conflict.""" + move_id: str team_abbrev: str players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team) @@ -81,6 +87,7 @@ class CancelledMove: @dataclass class ThawReport: """Complete thaw report for admin review.""" + week: int season: int timestamp: datetime @@ -94,8 +101,7 @@ class ThawReport: async def resolve_contested_transactions( - transactions: List[Transaction], - season: int + transactions: List[Transaction], season: int ) -> Tuple[List[str], List[str], List[ConflictResolution]]: """ Resolve contested transactions where multiple teams want the same player. @@ -109,7 +115,7 @@ async def resolve_contested_transactions( Returns: Tuple of (winning_move_ids, losing_move_ids, conflict_resolutions) """ - logger = get_contextual_logger(f'{__name__}.resolve_contested_transactions') + logger = get_contextual_logger(f"{__name__}.resolve_contested_transactions") # Group transactions by player name player_transactions: Dict[str, List[Transaction]] = {} @@ -118,7 +124,7 @@ async def resolve_contested_transactions( player_name = transaction.player.name.lower() # Only consider transactions where a team is acquiring a player (not FA drops) - if transaction.newteam.abbrev.upper() != 'FA': + if transaction.newteam.abbrev.upper() != "FA": if player_name not in player_transactions: player_transactions[player_name] = [] player_transactions[player_name].append(transaction) @@ -130,7 +136,9 @@ async def resolve_contested_transactions( for player_name, player_transactions_list in player_transactions.items(): if len(player_transactions_list) > 1: contested_players[player_name] = player_transactions_list - logger.info(f"Contested player: {player_name} ({len(player_transactions_list)} teams)") + logger.info( + f"Contested player: {player_name} ({len(player_transactions_list)} teams)" + ) else: # Non-contested, automatically wins non_contested_moves.add(player_transactions_list[0].moveid) @@ -143,50 +151,66 @@ async def resolve_contested_transactions( for player_name, contested_transactions in contested_players.items(): priorities: List[TransactionPriority] = [] # Track standings data for each team for report - team_standings_data: Dict[str, Tuple[int, int, float]] = {} # abbrev -> (wins, losses, win_pct) + team_standings_data: Dict[str, Tuple[int, int, float]] = ( + {} + ) # abbrev -> (wins, losses, win_pct) for transaction in contested_transactions: # Get team for priority calculation # If adding to MiL team, use the parent ML team for standings - if transaction.newteam.abbrev.endswith('MiL'): + if transaction.newteam.abbrev.endswith("MiL"): team_abbrev = transaction.newteam.abbrev[:-3] # Remove 'MiL' suffix else: team_abbrev = transaction.newteam.abbrev try: # Get team standings to calculate win percentage - standings = await standings_service.get_team_standings(team_abbrev, season) + standings = await standings_service.get_team_standings( + team_abbrev, season + ) - if standings and standings.wins is not None and standings.losses is not None: + if ( + standings + and standings.wins is not None + and standings.losses is not None + ): total_games = standings.wins + standings.losses win_pct = standings.wins / total_games if total_games > 0 else 0.0 team_standings_data[transaction.newteam.abbrev] = ( - standings.wins, standings.losses, win_pct + standings.wins, + standings.losses, + win_pct, ) else: win_pct = 0.0 team_standings_data[transaction.newteam.abbrev] = (0, 0, 0.0) - logger.warning(f"Could not get standings for {team_abbrev}, using 0.0 win%") + logger.warning( + f"Could not get standings for {team_abbrev}, using 0.0 win%" + ) # Add small random component for tiebreaking (5 decimal precision) random_component = random.randint(10000, 99999) * 0.00000001 tiebreaker = win_pct + random_component - priorities.append(TransactionPriority( - transaction=transaction, - team_win_percentage=win_pct, - tiebreaker=tiebreaker - )) + priorities.append( + TransactionPriority( + transaction=transaction, + team_win_percentage=win_pct, + tiebreaker=tiebreaker, + ) + ) except Exception as e: logger.error(f"Error calculating priority for {team_abbrev}: {e}") team_standings_data[transaction.newteam.abbrev] = (0, 0, 0.0) # Give them 0.0 priority on error - priorities.append(TransactionPriority( - transaction=transaction, - team_win_percentage=0.0, - tiebreaker=random.randint(10000, 99999) * 0.00000001 - )) + priorities.append( + TransactionPriority( + transaction=transaction, + team_win_percentage=0.0, + tiebreaker=random.randint(10000, 99999) * 0.00000001, + ) + ) # Sort by tiebreaker (lowest win% wins - worst teams get priority) priorities.sort() @@ -204,7 +228,7 @@ async def resolve_contested_transactions( wins=winner_standings[0], losses=winner_standings[1], win_pct=winner_standings[2], - move_id=winner.transaction.moveid + move_id=winner.transaction.moveid, ) loser_contenders: List[ConflictContender] = [] @@ -224,7 +248,7 @@ async def resolve_contested_transactions( wins=loser_standings[0], losses=loser_standings[1], win_pct=loser_standings[2], - move_id=loser.transaction.moveid + move_id=loser.transaction.moveid, ) loser_contenders.append(loser_contender) all_contenders.append(loser_contender) @@ -236,13 +260,15 @@ async def resolve_contested_transactions( # Get player info from first transaction (they all have same player) player = contested_transactions[0].player - conflict_resolutions.append(ConflictResolution( - player_name=player.name, - player_swar=player.wara, - contenders=all_contenders, - winner=winner_contender, - losers=loser_contenders - )) + conflict_resolutions.append( + ConflictResolution( + player_name=player.name, + player_swar=player.wara, + contenders=all_contenders, + winner=winner_contender, + losers=loser_contenders, + ) + ) # Add non-contested moves to winners winning_move_ids.update(non_contested_moves) @@ -255,7 +281,7 @@ class TransactionFreezeTask: def __init__(self, bot: commands.Bot): self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.TransactionFreezeTask') + self.logger = get_contextual_logger(f"{__name__}.TransactionFreezeTask") # Track last execution to prevent duplicate operations self.last_freeze_week: int | None = None @@ -288,7 +314,9 @@ class TransactionFreezeTask: # Skip if offseason mode is enabled if config.offseason_flag: - self.logger.info("Skipping freeze/thaw operations - offseason mode enabled") + self.logger.info( + "Skipping freeze/thaw operations - offseason mode enabled" + ) return # Get current league state @@ -304,7 +332,7 @@ class TransactionFreezeTask: weekday=now.weekday(), hour=now.hour, current_week=current.week, - freeze_status=current.freeze + freeze_status=current.freeze, ) # BEGIN FREEZE: Monday at 00:00, not already frozen @@ -312,13 +340,23 @@ class TransactionFreezeTask: # Only run if we haven't already frozen this week # Track the week we're freezing FROM (before increment) if self.last_freeze_week != current.week: - freeze_from_week = current.week # Save BEFORE _begin_freeze modifies it - self.logger.info("Triggering freeze begin", current_week=current.week) + freeze_from_week = ( + current.week + ) # Save BEFORE _begin_freeze modifies it + self.logger.info( + "Triggering freeze begin", current_week=current.week + ) await self._begin_freeze(current) - self.last_freeze_week = freeze_from_week # Track the week we froze FROM - self.error_notification_sent = False # Reset error flag for new cycle + self.last_freeze_week = ( + freeze_from_week # Track the week we froze FROM + ) + self.error_notification_sent = ( + False # Reset error flag for new cycle + ) else: - self.logger.debug("Freeze already executed for week", week=current.week) + self.logger.debug( + "Freeze already executed for week", week=current.week + ) # END FREEZE: Saturday at 00:00, currently frozen elif now.weekday() == 5 and now.hour == 0 and current.freeze: @@ -327,9 +365,13 @@ class TransactionFreezeTask: self.logger.info("Triggering freeze end", current_week=current.week) await self._end_freeze(current) self.last_thaw_week = current.week - self.error_notification_sent = False # Reset error flag for new cycle + self.error_notification_sent = ( + False # Reset error flag for new cycle + ) else: - self.logger.debug("Thaw already executed for week", week=current.week) + self.logger.debug( + "Thaw already executed for week", week=current.week + ) else: self.logger.debug("No freeze/thaw action needed at this time") @@ -375,8 +417,7 @@ class TransactionFreezeTask: # Increment week and set freeze via service new_week = current.week + 1 updated_current = await league_service.update_current_state( - week=new_week, - freeze=True + week=new_week, freeze=True ) if not updated_current: @@ -449,15 +490,18 @@ class TransactionFreezeTask: try: # Get non-frozen, non-cancelled transactions for current week via service transactions = await transaction_service.get_regular_transactions_by_week( - season=current.season, - week=current.week + season=current.season, week=current.week ) if not transactions: - self.logger.info(f"No regular transactions to process for week {current.week}") + self.logger.info( + f"No regular transactions to process for week {current.week}" + ) return - self.logger.info(f"Processing {len(transactions)} regular transactions for week {current.week}") + self.logger.info( + f"Processing {len(transactions)} regular transactions for week {current.week}" + ) # Execute player roster updates for all transactions success_count = 0 @@ -470,7 +514,7 @@ class TransactionFreezeTask: player_id=transaction.player.id, new_team_id=transaction.newteam.id, player_name=transaction.player.name, - dem_week=current.week + 2 + dem_week=current.week + 2, ) success_count += 1 @@ -482,7 +526,7 @@ class TransactionFreezeTask: f"Failed to execute transaction for {transaction.player.name}", player_id=transaction.player.id, new_team_id=transaction.newteam.id, - error=str(e) + error=str(e), ) failure_count += 1 @@ -490,7 +534,7 @@ class TransactionFreezeTask: f"Transaction execution complete for week {current.week}", success=success_count, failures=failure_count, - total=len(transactions) + total=len(transactions), ) except Exception as e: @@ -514,11 +558,13 @@ class TransactionFreezeTask: transactions = await transaction_service.get_frozen_transactions_by_week( season=current.season, week_start=current.week, - week_end=current.week + 1 + week_end=current.week + 1, ) if not transactions: - self.logger.warning(f"No frozen transactions to process for week {current.week}") + self.logger.warning( + f"No frozen transactions to process for week {current.week}" + ) # Still post an empty report for visibility empty_report = ThawReport( week=current.week, @@ -530,23 +576,26 @@ class TransactionFreezeTask: conflict_count=0, conflicts=[], thawed_moves=[], - cancelled_moves=[] + cancelled_moves=[], ) await self._post_thaw_report(empty_report) return - self.logger.info(f"Processing {len(transactions)} frozen transactions for week {current.week}") + self.logger.info( + f"Processing {len(transactions)} frozen transactions for week {current.week}" + ) # Resolve contested transactions - winning_move_ids, losing_move_ids, conflict_resolutions = await resolve_contested_transactions( - transactions, - current.season + winning_move_ids, losing_move_ids, conflict_resolutions = ( + await resolve_contested_transactions(transactions, current.season) ) # Build mapping from conflict player to winner for cancelled move tracking conflict_player_to_winner: Dict[str, str] = {} for conflict in conflict_resolutions: - conflict_player_to_winner[conflict.player_name.lower()] = conflict.winner.team_abbrev + conflict_player_to_winner[conflict.player_name.lower()] = ( + conflict.winner.team_abbrev + ) # Track cancelled moves for report cancelled_moves_report: List[CancelledMove] = [] @@ -555,24 +604,34 @@ class TransactionFreezeTask: for losing_move_id in losing_move_ids: try: # Get all moves with this moveid (could be multiple players in one transaction) - losing_moves = [t for t in transactions if t.moveid == losing_move_id] + losing_moves = [ + t for t in transactions if t.moveid == losing_move_id + ] if losing_moves: # Cancel the entire transaction (all moves with same moveid) for move in losing_moves: - success = await transaction_service.cancel_transaction(move.moveid) + success = await transaction_service.cancel_transaction( + move.moveid + ) if not success: - self.logger.warning(f"Failed to cancel transaction {move.moveid}") + self.logger.warning( + f"Failed to cancel transaction {move.moveid}" + ) # Notify the GM(s) about cancellation first_move = losing_moves[0] # Determine which team to notify (the team that was trying to acquire) - team_for_notification = (first_move.newteam - if first_move.newteam.abbrev.upper() != 'FA' - else first_move.oldteam) + team_for_notification = ( + first_move.newteam + if first_move.newteam.abbrev.upper() != "FA" + else first_move.oldteam + ) - await self._notify_gm_of_cancellation(first_move, team_for_notification) + await self._notify_gm_of_cancellation( + first_move, team_for_notification + ) # Find which player caused the conflict contested_player = "" @@ -586,16 +645,23 @@ class TransactionFreezeTask: # Build report entry players = [ - (move.player.name, move.player.wara, move.oldteam.abbrev, move.newteam.abbrev) + ( + move.player.name, + move.player.wara, + move.oldteam.abbrev, + move.newteam.abbrev, + ) for move in losing_moves ] - cancelled_moves_report.append(CancelledMove( - move_id=losing_move_id, - team_abbrev=team_for_notification.abbrev, - players=players, - lost_to=lost_to, - contested_player=contested_player - )) + cancelled_moves_report.append( + CancelledMove( + move_id=losing_move_id, + team_abbrev=team_for_notification.abbrev, + players=players, + lost_to=lost_to, + contested_player=contested_player, + ) + ) contested_players = [move.player.name for move in losing_moves] self.logger.info( @@ -604,7 +670,9 @@ class TransactionFreezeTask: ) except Exception as e: - self.logger.error(f"Error cancelling transaction {losing_move_id}: {e}") + self.logger.error( + f"Error cancelling transaction {losing_move_id}: {e}" + ) # Track thawed moves for report thawed_moves_report: List[ThawedMove] = [] @@ -613,13 +681,19 @@ class TransactionFreezeTask: for winning_move_id in winning_move_ids: try: # Get all moves with this moveid - winning_moves = [t for t in transactions if t.moveid == winning_move_id] + winning_moves = [ + t for t in transactions if t.moveid == winning_move_id + ] for move in winning_moves: # Unfreeze the transaction via service - success = await transaction_service.unfreeze_transaction(move.moveid) + success = await transaction_service.unfreeze_transaction( + move.moveid + ) if not success: - self.logger.warning(f"Failed to unfreeze transaction {move.moveid}") + self.logger.warning( + f"Failed to unfreeze transaction {move.moveid}" + ) # Post to transaction log await self._post_transaction_to_log(winning_move_id, transactions) @@ -629,32 +703,43 @@ class TransactionFreezeTask: first_move = winning_moves[0] # Extract timestamp from moveid (format: Season-XXX-Week-XX-DD-HH:MM:SS) try: - parts = winning_move_id.split('-') + parts = winning_move_id.split("-") submitted_at = parts[-1] if len(parts) >= 6 else "Unknown" except Exception: submitted_at = "Unknown" # Determine team abbrev - if first_move.newteam.abbrev.upper() != 'FA': + if first_move.newteam.abbrev.upper() != "FA": team_abbrev = first_move.newteam.abbrev else: team_abbrev = first_move.oldteam.abbrev players = [ - (move.player.name, move.player.wara, move.oldteam.abbrev, move.newteam.abbrev) + ( + move.player.name, + move.player.wara, + move.oldteam.abbrev, + move.newteam.abbrev, + ) for move in winning_moves ] - thawed_moves_report.append(ThawedMove( - move_id=winning_move_id, - team_abbrev=team_abbrev, - players=players, - submitted_at=submitted_at - )) + thawed_moves_report.append( + ThawedMove( + move_id=winning_move_id, + team_abbrev=team_abbrev, + players=players, + submitted_at=submitted_at, + ) + ) - self.logger.info(f"Processed successful transaction {winning_move_id}") + self.logger.info( + f"Processed successful transaction {winning_move_id}" + ) except Exception as e: - self.logger.error(f"Error processing winning transaction {winning_move_id}: {e}") + self.logger.error( + f"Error processing winning transaction {winning_move_id}: {e}" + ) # Generate and post thaw report thaw_report = ThawReport( @@ -667,7 +752,7 @@ class TransactionFreezeTask: conflict_count=len(conflict_resolutions), conflicts=conflict_resolutions, thawed_moves=thawed_moves_report, - cancelled_moves=cancelled_moves_report + cancelled_moves=cancelled_moves_report, ) await self._post_thaw_report(thaw_report) @@ -685,7 +770,7 @@ class TransactionFreezeTask: player_id: int, new_team_id: int, player_name: str, - dem_week: Optional[int] = None + dem_week: Optional[int] = None, ) -> bool: """ Execute a player roster update via API PATCH. @@ -708,13 +793,11 @@ class TransactionFreezeTask: player_id=player_id, player_name=player_name, new_team_id=new_team_id, - dem_week=dem_week + dem_week=dem_week, ) updated_player = await player_service.update_player_team( - player_id, - new_team_id, - dem_week=dem_week + player_id, new_team_id, dem_week=dem_week ) # Verify response (200 or 204 indicates success) @@ -724,7 +807,7 @@ class TransactionFreezeTask: player_id=player_id, player_name=player_name, new_team_id=new_team_id, - dem_week=dem_week + dem_week=dem_week, ) return True else: @@ -733,7 +816,7 @@ class TransactionFreezeTask: player_id=player_id, player_name=player_name, new_team_id=new_team_id, - dem_week=dem_week + dem_week=dem_week, ) return False @@ -745,7 +828,7 @@ class TransactionFreezeTask: new_team_id=new_team_id, dem_week=dem_week, error=str(e), - exc_info=True + exc_info=True, ) raise @@ -764,34 +847,36 @@ class TransactionFreezeTask: self.logger.warning("Could not find guild for freeze announcement") return - channel = discord.utils.get(guild.text_channels, name='transaction-log') + channel = discord.utils.get(guild.text_channels, name="transaction-log") if not channel: self.logger.warning("Could not find transaction-log channel") return # Create announcement message (formatted like legacy bot) - week_num = f'Week {week}' - stars = '*' * 32 + week_num = f"Week {week}" + stars = "*" * 32 if is_beginning: message = ( - f'```\n' - f'{stars}\n' - f'{week_num:>9} Freeze Period Begins\n' - f'{stars}\n' - f'```' + f"```\n" + f"{stars}\n" + f"{week_num:>9} Freeze Period Begins\n" + f"{stars}\n" + f"```" ) else: message = ( - f'```\n' + f"```\n" f'{"*" * 30}\n' - f'{week_num:>9} Freeze Period Ends\n' + f"{week_num:>9} Freeze Period Ends\n" f'{"*" * 30}\n' - f'```' + f"```" ) await channel.send(message) - self.logger.info(f"Freeze announcement sent for week {week} ({'begin' if is_beginning else 'end'})") + self.logger.info( + f"Freeze announcement sent for week {week} ({'begin' if is_beginning else 'end'})" + ) except Exception as e: self.logger.error(f"Error sending freeze announcement: {e}") @@ -809,7 +894,7 @@ class TransactionFreezeTask: if not guild: return - info_channel = discord.utils.get(guild.text_channels, name='weekly-info') + info_channel = discord.utils.get(guild.text_channels, name="weekly-info") if not info_channel: self.logger.warning("Could not find weekly-info channel") return @@ -818,7 +903,7 @@ class TransactionFreezeTask: async for message in info_channel.history(limit=25): try: await message.delete() - except: + except Exception: pass # Ignore deletion errors # Determine season emoji @@ -835,17 +920,17 @@ class TransactionFreezeTask: is_div_week = current.week in [1, 3, 6, 14, 16, 18] weekly_str = ( - f'**Season**: {season_str}\n' - f'**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / ' - f'{night_str} / {day_str}' + f"**Season**: {season_str}\n" + f"**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / " + f"{night_str} / {day_str}" ) # Send info messages await info_channel.send( content=( - f'Each team has manage permissions in their home ballpark. ' - f'They may pin messages and rename the channel.\n\n' - f'**Make sure your ballpark starts with your team abbreviation.**' + f"Each team has manage permissions in their home ballpark. " + f"They may pin messages and rename the channel.\n\n" + f"**Make sure your ballpark starts with your team abbreviation.**" ) ) await info_channel.send(weekly_str) @@ -856,9 +941,7 @@ class TransactionFreezeTask: self.logger.error(f"Error posting weekly info: {e}") async def _post_transaction_to_log( - self, - move_id: str, - all_transactions: List[Transaction] + self, move_id: str, all_transactions: List[Transaction] ): """ Post a transaction to the transaction log channel. @@ -873,7 +956,7 @@ class TransactionFreezeTask: if not guild: return - channel = discord.utils.get(guild.text_channels, name='transaction-log') + channel = discord.utils.get(guild.text_channels, name="transaction-log") if not channel: return @@ -884,9 +967,15 @@ class TransactionFreezeTask: # Determine the team for the embed (team making the moves) first_move = moves[0] - if first_move.newteam.abbrev.upper() != 'FA' and 'IL' not in first_move.newteam.abbrev: + if ( + first_move.newteam.abbrev.upper() != "FA" + and "IL" not in first_move.newteam.abbrev + ): this_team = first_move.newteam - elif first_move.oldteam.abbrev.upper() != 'FA' and 'IL' not in first_move.oldteam.abbrev: + elif ( + first_move.oldteam.abbrev.upper() != "FA" + and "IL" not in first_move.oldteam.abbrev + ): this_team = first_move.oldteam else: # Default to newteam if both are FA/IL @@ -898,25 +987,29 @@ class TransactionFreezeTask: for move in moves: move_string += ( - f'**{move.player.name}** ({move.player.wara:.2f}) ' - f'from {move.oldteam.abbrev} to {move.newteam.abbrev}\n' + f"**{move.player.name}** ({move.player.wara:.2f}) " + f"from {move.oldteam.abbrev} to {move.newteam.abbrev}\n" ) # Create embed embed = EmbedTemplate.create_base_embed( - title=f'Week {week_num} Transaction', - description=this_team.sname if hasattr(this_team, 'sname') else this_team.lname, - color=EmbedColors.INFO + title=f"Week {week_num} Transaction", + description=( + this_team.sname if hasattr(this_team, "sname") else this_team.lname + ), + color=EmbedColors.INFO, ) # Set team color if available - if hasattr(this_team, 'color') and this_team.color: + if hasattr(this_team, "color") and this_team.color: try: - embed.color = discord.Color(int(this_team.color.replace('#', ''), 16)) - except: + embed.color = discord.Color( + int(this_team.color.replace("#", ""), 16) + ) + except Exception: pass # Use default color on error - embed.add_field(name='Player Moves', value=move_string, inline=False) + embed.add_field(name="Player Moves", value=move_string, inline=False) await channel.send(embed=embed) self.logger.info(f"Transaction posted to log: {move_id}") @@ -924,11 +1017,7 @@ class TransactionFreezeTask: except Exception as e: self.logger.error(f"Error posting transaction to log: {e}") - async def _notify_gm_of_cancellation( - self, - transaction: Transaction, - team - ): + async def _notify_gm_of_cancellation(self, transaction: Transaction, team): """ Send DM to GM(s) about cancelled transaction. @@ -943,27 +1032,31 @@ class TransactionFreezeTask: return cancel_text = ( - f'Your transaction for **{transaction.player.name}** has been cancelled ' - f'because another team successfully claimed them during the freeze period.' + f"Your transaction for **{transaction.player.name}** has been cancelled " + f"because another team successfully claimed them during the freeze period." ) # Notify GM1 - if hasattr(team, 'gmid') and team.gmid: + if hasattr(team, "gmid") and team.gmid: try: gm_one = guild.get_member(team.gmid) if gm_one: await gm_one.send(cancel_text) - self.logger.info(f"Cancellation notification sent to GM1 of {team.abbrev}") + self.logger.info( + f"Cancellation notification sent to GM1 of {team.abbrev}" + ) except Exception as e: self.logger.error(f"Could not notify GM1 of {team.abbrev}: {e}") # Notify GM2 if exists - if hasattr(team, 'gmid2') and team.gmid2: + if hasattr(team, "gmid2") and team.gmid2: try: gm_two = guild.get_member(team.gmid2) if gm_two: await gm_two.send(cancel_text) - self.logger.info(f"Cancellation notification sent to GM2 of {team.abbrev}") + self.logger.info( + f"Cancellation notification sent to GM2 of {team.abbrev}" + ) except Exception as e: self.logger.error(f"Could not notify GM2 of {team.abbrev}: {e}") @@ -986,30 +1079,43 @@ class TransactionFreezeTask: admin_channel = self.bot.get_channel(config.thaw_report_channel_id) if not admin_channel: - self.logger.warning("Could not find thaw report channel", channel_id=config.thaw_report_channel_id) + self.logger.warning( + "Could not find thaw report channel", + channel_id=config.thaw_report_channel_id, + ) return # Build the report content report_lines = [] # Header with summary - timestamp_str = report.timestamp.strftime('%B %d, %Y %H:%M UTC') + timestamp_str = report.timestamp.strftime("%B %d, %Y %H:%M UTC") report_lines.append(f"# Transaction Thaw Report") - report_lines.append(f"**Week {report.week}** | **Season {report.season}** | {timestamp_str}") - report_lines.append(f"**Total:** {report.total_moves} moves | **Thawed:** {report.thawed_count} | **Cancelled:** {report.cancelled_count} | **Conflicts:** {report.conflict_count}") + report_lines.append( + f"**Week {report.week}** | **Season {report.season}** | {timestamp_str}" + ) + report_lines.append( + f"**Total:** {report.total_moves} moves | **Thawed:** {report.thawed_count} | **Cancelled:** {report.cancelled_count} | **Conflicts:** {report.conflict_count}" + ) report_lines.append("") # Conflict Resolution section (if any) if report.conflicts: report_lines.append("## Conflict Resolution") for conflict in report.conflicts: - report_lines.append(f"**{conflict.player_name}** (sWAR: {conflict.player_swar:.1f})") - contenders_str = " vs ".join([ - f"{c.team_abbrev} ({c.wins}-{c.losses})" - for c in conflict.contenders - ]) + report_lines.append( + f"**{conflict.player_name}** (sWAR: {conflict.player_swar:.1f})" + ) + contenders_str = " vs ".join( + [ + f"{c.team_abbrev} ({c.wins}-{c.losses})" + for c in conflict.contenders + ] + ) report_lines.append(f"- Contested by: {contenders_str}") - report_lines.append(f"- **Awarded to: {conflict.winner.team_abbrev}** (worst record wins)") + report_lines.append( + f"- **Awarded to: {conflict.winner.team_abbrev}** (worst record wins)" + ) report_lines.append("") # Thawed Moves section @@ -1018,7 +1124,9 @@ class TransactionFreezeTask: for move in report.thawed_moves: report_lines.append(f"**{move.move_id}** | {move.team_abbrev}") for player_name, swar, old_team, new_team in move.players: - report_lines.append(f" - {player_name} ({swar:.1f}): {old_team} → {new_team}") + report_lines.append( + f" - {player_name} ({swar:.1f}): {old_team} → {new_team}" + ) else: report_lines.append("*No moves thawed*") report_lines.append("") @@ -1027,10 +1135,18 @@ class TransactionFreezeTask: report_lines.append("## Cancelled Moves") if report.cancelled_moves: for move in report.cancelled_moves: - lost_info = f" (lost {move.contested_player} to {move.lost_to})" if move.lost_to else "" - report_lines.append(f"**{move.move_id}** | {move.team_abbrev}{lost_info}") + lost_info = ( + f" (lost {move.contested_player} to {move.lost_to})" + if move.lost_to + else "" + ) + report_lines.append( + f"**{move.move_id}** | {move.team_abbrev}{lost_info}" + ) for player_name, swar, old_team, new_team in move.players: - report_lines.append(f" - ❌ {player_name} ({swar:.1f}): {old_team} → {new_team}") + report_lines.append( + f" - ❌ {player_name} ({swar:.1f}): {old_team} → {new_team}" + ) else: report_lines.append("*No moves cancelled*") diff --git a/views/transaction_embed.py b/views/transaction_embed.py index cca7153..69a12fb 100644 --- a/views/transaction_embed.py +++ b/views/transaction_embed.py @@ -380,12 +380,14 @@ class SubmitConfirmationModal(discord.ui.Modal): if "Transaction Builder" in message.embeds[0].title: # type: ignore await message.edit(embed=completion_embed, view=view) break - except: + except Exception: pass except Exception as e: + self.logger.error(f"Error submitting transaction: {e}", exc_info=True) await interaction.followup.send( - f"❌ Error submitting transaction: {str(e)}", ephemeral=True + "❌ Error submitting transaction. Please try again or contact an admin.", + ephemeral=True, ) From f64fee8d2e693fde3ff386131774e372553a1f09 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Feb 2026 11:35:04 -0600 Subject: [PATCH 4/6] fix: remove 226 unused imports across the codebase (closes #33) Ran `ruff check --select F401 --fix` to auto-remove 221 unused imports, manually removed 4 unused `import discord` from package __init__.py files, and fixed test import for DISAPPOINTMENT_TIERS to reference canonical location. Co-Authored-By: Claude Opus 4.6 --- bot.py | 2 +- commands/admin/__init__.py | 20 +-- commands/admin/league_management.py | 3 +- commands/admin/users.py | 3 +- commands/custom_commands/main.py | 5 +- commands/dice/rolls.py | 3 - commands/draft/list.py | 1 - commands/examples/enhanced_player.py | 4 +- commands/gameplay/scorebug.py | 4 +- commands/help/main.py | 3 +- commands/injuries/management.py | 3 - commands/league/__init__.py | 20 +-- commands/league/info.py | 4 - commands/league/standings.py | 1 - commands/profile/images.py | 2 +- commands/soak/giphy_service.py | 1 - commands/teams/__init__.py | 20 +-- commands/teams/info.py | 2 - commands/teams/roster.py | 6 +- commands/transactions/__init__.py | 28 ++-- commands/transactions/dropadd.py | 3 +- commands/transactions/ilmove.py | 1 - commands/transactions/trade.py | 1 - .../transactions/trade_channel_tracker.py | 1 - commands/utilities/charts.py | 2 +- commands/utilities/weather.py | 2 +- models/custom_command.py | 2 +- models/draft_list.py | 2 +- models/draft_pick.py | 4 +- models/injury.py | 2 +- models/trade.py | 3 +- models/transaction.py | 2 +- services/base_service.py | 1 - services/custom_commands_service.py | 3 +- services/decision_service.py | 1 - services/draft_list_service.py | 2 - services/draft_pick_service.py | 1 - services/draft_service.py | 1 - services/draft_sheet_service.py | 1 - services/help_commands_service.py | 1 - services/injury_service.py | 1 - services/roster_service.py | 1 - services/schedule_service.py | 2 - services/scorebug_service.py | 2 +- services/standings_service.py | 1 - services/stats_service.py | 4 +- services/trade_builder.py | 6 +- services/transaction_builder.py | 5 +- services/transaction_service.py | 3 +- tasks/custom_command_cleanup.py | 4 +- tasks/draft_monitor.py | 5 - tasks/live_scorebug_tracker.py | 3 +- tests/conftest.py | 6 +- tests/factories.py | 4 +- tests/test_api_client.py | 1 - tests/test_commands_charts.py | 5 +- tests/test_commands_dice.py | 2 +- tests/test_commands_dropadd.py | 4 - tests/test_commands_profile_images.py | 2 - tests/test_commands_soak.py | 128 +++++++++--------- tests/test_commands_teams_branding.py | 1 - tests/test_commands_voice.py | 2 - tests/test_config.py | 1 - tests/test_constants.py | 1 - tests/test_dropadd_integration.py | 5 +- tests/test_models.py | 1 - tests/test_models_trade.py | 1 - tests/test_models_transaction.py | 3 - tests/test_services_base_service.py | 1 - tests/test_services_custom_commands.py | 12 +- tests/test_services_draft_sheet.py | 3 +- tests/test_services_help_commands.py | 2 +- tests/test_services_injury.py | 2 +- tests/test_services_league_service.py | 1 - tests/test_services_trade_builder.py | 2 +- tests/test_services_transaction.py | 3 +- tests/test_services_transaction_builder.py | 2 - tests/test_tasks_custom_command_cleanup.py | 3 - tests/test_tasks_transaction_freeze.py | 3 +- tests/test_transactions_integration.py | 1 - tests/test_utils_decorators.py | 4 +- tests/test_utils_helpers.py | 1 - tests/test_utils_logging.py | 1 - tests/test_utils_scorebug_helpers.py | 1 - tests/test_views_custom_commands.py | 7 +- tests/test_views_injury_modals.py | 4 +- tests/test_views_transaction_embed.py | 5 +- utils/autocomplete.py | 2 +- utils/helpers.py | 1 - utils/injury_log.py | 3 +- utils/random_gen.py | 2 +- utils/scorebug_helpers.py | 1 - utils/transaction_logging.py | 1 - views/base.py | 4 +- views/common.py | 5 +- views/custom_commands.py | 4 +- views/draft_views.py | 2 - views/embeds.py | 2 +- views/help_commands.py | 6 +- views/modals.py | 4 +- views/players.py | 2 - views/trade_embed.py | 2 +- views/transaction_embed.py | 3 - 103 files changed, 183 insertions(+), 294 deletions(-) diff --git a/bot.py b/bot.py index 1bcd26f..ca1c07e 100644 --- a/bot.py +++ b/bot.py @@ -18,7 +18,7 @@ from config import get_config from exceptions import BotException from api.client import get_global_client, cleanup_global_client from utils.random_gen import STARTUP_WATCHING, random_from_list -from views.embeds import EmbedTemplate, EmbedColors +from views.embeds import EmbedTemplate def setup_logging(): diff --git a/commands/admin/__init__.py b/commands/admin/__init__.py index 4ab3e0c..6628542 100644 --- a/commands/admin/__init__.py +++ b/commands/admin/__init__.py @@ -3,23 +3,23 @@ Admin command package for Discord Bot v2.0 Contains administrative commands for league management. """ + import logging from typing import List, Tuple, Type -import discord from discord.ext import commands from .management import AdminCommands from .users import UserManagementCommands from .league_management import LeagueManagementCommands -logger = logging.getLogger(f'{__name__}.setup_admin') +logger = logging.getLogger(f"{__name__}.setup_admin") async def setup_admin(bot: commands.Bot) -> Tuple[int, int, List[str]]: """ Set up admin command modules. - + Returns: Tuple of (successful_loads, failed_loads, failed_modules) """ @@ -28,11 +28,11 @@ async def setup_admin(bot: commands.Bot) -> Tuple[int, int, List[str]]: ("UserManagementCommands", UserManagementCommands), ("LeagueManagementCommands", LeagueManagementCommands), ] - + successful = 0 failed = 0 failed_modules = [] - + for cog_name, cog_class in admin_cogs: try: await bot.add_cog(cog_class(bot)) @@ -42,13 +42,15 @@ async def setup_admin(bot: commands.Bot) -> Tuple[int, int, List[str]]: logger.error(f"❌ Failed to load admin command module {cog_name}: {e}") failed += 1 failed_modules.append(cog_name) - + # Log summary if failed == 0: logger.info(f"🎉 All {successful} admin command modules loaded successfully") else: - logger.warning(f"⚠️ Admin commands loaded with issues: {successful} successful, {failed} failed") + logger.warning( + f"⚠️ Admin commands loaded with issues: {successful} successful, {failed} failed" + ) if failed_modules: logger.warning(f"Failed modules: {', '.join(failed_modules)}") - - return successful, failed, failed_modules \ No newline at end of file + + return successful, failed, failed_modules diff --git a/commands/admin/league_management.py b/commands/admin/league_management.py index 2f24f9e..68947d4 100644 --- a/commands/admin/league_management.py +++ b/commands/admin/league_management.py @@ -10,11 +10,10 @@ import discord from discord.ext import commands from discord import app_commands -from config import get_config from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.permissions import league_admin_only -from views.embeds import EmbedColors, EmbedTemplate +from views.embeds import EmbedTemplate from services.league_service import league_service from services.transaction_service import transaction_service from tasks.transaction_freeze import resolve_contested_transactions diff --git a/commands/admin/users.py b/commands/admin/users.py index 65520bf..665c40e 100644 --- a/commands/admin/users.py +++ b/commands/admin/users.py @@ -4,8 +4,7 @@ Admin User Management Commands User-focused administrative commands for moderation and user management. """ from typing import Optional, Union -import asyncio -from datetime import datetime, timedelta +from datetime import timedelta import discord from discord.ext import commands diff --git a/commands/custom_commands/main.py b/commands/custom_commands/main.py index fe942c9..e72130f 100644 --- a/commands/custom_commands/main.py +++ b/commands/custom_commands/main.py @@ -10,9 +10,7 @@ from discord.ext import commands from services.custom_commands_service import ( custom_commands_service, - CustomCommandNotFoundError, - CustomCommandExistsError, - CustomCommandPermissionError + CustomCommandNotFoundError ) from models.custom_command import CustomCommandSearchFilters from utils.logging import get_contextual_logger @@ -28,7 +26,6 @@ from views.custom_commands import ( CustomCommandSearchModal, SingleCommandManagementView ) -from exceptions import BotException class CustomCommandsCommands(commands.Cog): diff --git a/commands/dice/rolls.py b/commands/dice/rolls.py index 0935005..b974cbe 100644 --- a/commands/dice/rolls.py +++ b/commands/dice/rolls.py @@ -4,14 +4,11 @@ Dice Rolling Commands Implements slash commands for dice rolling functionality required for gameplay. """ import random -from typing import Optional import discord from discord.ext import commands -from models.team import Team from services.team_service import team_service -from utils import team_utils from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.team_utils import get_user_major_league_team diff --git a/commands/draft/list.py b/commands/draft/list.py index 42cc376..5ced610 100644 --- a/commands/draft/list.py +++ b/commands/draft/list.py @@ -6,7 +6,6 @@ Manage team auto-draft queue (draft board). from typing import List, Optional import discord -from discord import app_commands from discord.ext import commands from config import get_config diff --git a/commands/examples/enhanced_player.py b/commands/examples/enhanced_player.py index 693b510..30b4143 100644 --- a/commands/examples/enhanced_player.py +++ b/commands/examples/enhanced_player.py @@ -14,7 +14,6 @@ from services.player_service import player_service from models.player import Player from utils.logging import get_contextual_logger from utils.decorators import logged_command -from exceptions import BotException # Import our new view components from views import ( @@ -24,8 +23,7 @@ from views import ( PlayerSelectionView, DetailedInfoView, SearchResultsView, - PlayerSearchModal, - PaginationView + PlayerSearchModal ) diff --git a/commands/gameplay/scorebug.py b/commands/gameplay/scorebug.py index 8030a2a..24b996a 100644 --- a/commands/gameplay/scorebug.py +++ b/commands/gameplay/scorebug.py @@ -7,13 +7,13 @@ import discord from discord.ext import commands from discord import app_commands -from services.scorebug_service import ScorebugData, ScorebugService +from services.scorebug_service import ScorebugService from services.team_service import team_service from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.permissions import league_only from utils.scorebug_helpers import create_scorebug_embed -from views.embeds import EmbedTemplate, EmbedColors +from views.embeds import EmbedTemplate from exceptions import SheetsException from .scorecard_tracker import ScorecardTracker diff --git a/commands/help/main.py b/commands/help/main.py index f0bde14..ebfbf66 100644 --- a/commands/help/main.py +++ b/commands/help/main.py @@ -17,7 +17,7 @@ from services.help_commands_service import ( from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.permissions import league_admin_only -from views.embeds import EmbedTemplate, EmbedColors +from views.embeds import EmbedTemplate from views.help_commands import ( HelpCommandCreateModal, HelpCommandEditModal, @@ -25,7 +25,6 @@ from views.help_commands import ( HelpCommandListView, create_help_topic_embed ) -from exceptions import BotException async def help_topic_autocomplete( diff --git a/commands/injuries/management.py b/commands/injuries/management.py index 9e9fb4b..cf030dd 100644 --- a/commands/injuries/management.py +++ b/commands/injuries/management.py @@ -18,9 +18,6 @@ from discord import app_commands from discord.ext import commands from config import get_config -from models.current import Current -from models.injury import Injury -from models.player import Player from models.team import RosterType from services.player_service import player_service from services.injury_service import injury_service diff --git a/commands/league/__init__.py b/commands/league/__init__.py index a674b5c..f234141 100644 --- a/commands/league/__init__.py +++ b/commands/league/__init__.py @@ -3,10 +3,10 @@ League command package for Discord Bot v2.0 Provides league-wide slash commands for standings and current state. """ + import logging from typing import List, Tuple, Type -import discord from discord.ext import commands from .info import LeagueInfoCommands @@ -14,13 +14,13 @@ from .standings import StandingsCommands from .schedule import ScheduleCommands from .submit_scorecard import SubmitScorecardCommands -logger = logging.getLogger(f'{__name__}.setup_league') +logger = logging.getLogger(f"{__name__}.setup_league") async def setup_league(bot: commands.Bot) -> Tuple[int, int, List[str]]: """ Set up league command modules. - + Returns: Tuple of (successful_loads, failed_loads, failed_modules) """ @@ -30,11 +30,11 @@ async def setup_league(bot: commands.Bot) -> Tuple[int, int, List[str]]: ("ScheduleCommands", ScheduleCommands), ("SubmitScorecardCommands", SubmitScorecardCommands), ] - + successful = 0 failed = 0 failed_modules = [] - + for cog_name, cog_class in league_cogs: try: await bot.add_cog(cog_class(bot)) @@ -44,13 +44,15 @@ async def setup_league(bot: commands.Bot) -> Tuple[int, int, List[str]]: logger.error(f"❌ Failed to load league command module {cog_name}: {e}") failed += 1 failed_modules.append(cog_name) - + # Log summary if failed == 0: logger.info(f"🎉 All {successful} league command modules loaded successfully") else: - logger.warning(f"⚠️ League commands loaded with issues: {successful} successful, {failed} failed") + logger.warning( + f"⚠️ League commands loaded with issues: {successful} successful, {failed} failed" + ) if failed_modules: logger.warning(f"Failed modules: {', '.join(failed_modules)}") - - return successful, failed, failed_modules \ No newline at end of file + + return successful, failed, failed_modules diff --git a/commands/league/info.py b/commands/league/info.py index f5d4a78..ccd5f92 100644 --- a/commands/league/info.py +++ b/commands/league/info.py @@ -1,17 +1,13 @@ """ League information commands for Discord Bot v2.0 """ -import logging -from typing import Optional import discord from discord.ext import commands from services import league_service -from config import get_config from utils.logging import get_contextual_logger from utils.decorators import logged_command -from exceptions import BotException from utils.permissions import requires_team from views.embeds import EmbedTemplate diff --git a/commands/league/standings.py b/commands/league/standings.py index 799b3f1..0bd255f 100644 --- a/commands/league/standings.py +++ b/commands/league/standings.py @@ -9,7 +9,6 @@ import discord from discord.ext import commands from config import get_config -from models.team import Team from services.standings_service import standings_service from utils.logging import get_contextual_logger from utils.decorators import logged_command diff --git a/commands/profile/images.py b/commands/profile/images.py index a3308b4..ae89e20 100644 --- a/commands/profile/images.py +++ b/commands/profile/images.py @@ -4,7 +4,7 @@ Player Image Management Commands Allows users to update player fancy card and headshot images for players on teams they own. Admins can update any player's images. """ -from typing import Optional, List, Tuple +from typing import List, Tuple import asyncio import aiohttp diff --git a/commands/soak/giphy_service.py b/commands/soak/giphy_service.py index 6b3fcca..5cb5390 100644 --- a/commands/soak/giphy_service.py +++ b/commands/soak/giphy_service.py @@ -9,7 +9,6 @@ All new code should import from services.giphy_service instead. from services import giphy_service # Re-export tier configuration for backwards compatibility -from services.giphy_service import DISAPPOINTMENT_TIERS def get_tier_for_seconds(seconds_elapsed): diff --git a/commands/teams/__init__.py b/commands/teams/__init__.py index a062c44..7e1b593 100644 --- a/commands/teams/__init__.py +++ b/commands/teams/__init__.py @@ -3,23 +3,23 @@ Team command package for Discord Bot v2.0 Provides team-related slash commands for the SBA league. """ + import logging from typing import List, Tuple, Type -import discord from discord.ext import commands from .info import TeamInfoCommands from .roster import TeamRosterCommands from .branding import BrandingCommands -logger = logging.getLogger(f'{__name__}.setup_teams') +logger = logging.getLogger(f"{__name__}.setup_teams") async def setup_teams(bot: commands.Bot) -> Tuple[int, int, List[str]]: """ Set up team command modules. - + Returns: Tuple of (successful_loads, failed_loads, failed_modules) """ @@ -28,11 +28,11 @@ async def setup_teams(bot: commands.Bot) -> Tuple[int, int, List[str]]: ("TeamRosterCommands", TeamRosterCommands), ("BrandingCommands", BrandingCommands), ] - + successful = 0 failed = 0 failed_modules = [] - + for cog_name, cog_class in team_cogs: try: await bot.add_cog(cog_class(bot)) @@ -42,13 +42,15 @@ async def setup_teams(bot: commands.Bot) -> Tuple[int, int, List[str]]: logger.error(f"❌ Failed to load team command module {cog_name}: {e}") failed += 1 failed_modules.append(cog_name) - + # Log summary if failed == 0: logger.info(f"🎉 All {successful} team command modules loaded successfully") else: - logger.warning(f"⚠️ Team commands loaded with issues: {successful} successful, {failed} failed") + logger.warning( + f"⚠️ Team commands loaded with issues: {successful} successful, {failed} failed" + ) if failed_modules: logger.warning(f"Failed modules: {', '.join(failed_modules)}") - - return successful, failed, failed_modules \ No newline at end of file + + return successful, failed, failed_modules diff --git a/commands/teams/info.py b/commands/teams/info.py index dde01f3..012edff 100644 --- a/commands/teams/info.py +++ b/commands/teams/info.py @@ -1,7 +1,6 @@ """ Team information commands for Discord Bot v2.0 """ -import logging from typing import Optional from config import get_config @@ -12,7 +11,6 @@ from services import team_service, player_service from models.team import RosterType, Team from utils.logging import get_contextual_logger from utils.decorators import logged_command -from exceptions import BotException from views.embeds import EmbedTemplate, EmbedColors from views.base import PaginationView diff --git a/commands/teams/roster.py b/commands/teams/roster.py index ce7b7e3..1acbddc 100644 --- a/commands/teams/roster.py +++ b/commands/teams/roster.py @@ -1,19 +1,17 @@ """ Team roster commands for Discord Bot v2.0 """ -import logging -from typing import Optional, Dict, Any, List +from typing import Dict, Any, List import discord from discord.ext import commands from config import get_config from models.player import Player -from services import team_service, player_service +from services import team_service from models.team import Team from utils.logging import get_contextual_logger from utils.decorators import logged_command -from exceptions import BotException from utils.permissions import requires_team from views.embeds import EmbedTemplate, EmbedColors diff --git a/commands/transactions/__init__.py b/commands/transactions/__init__.py index ed46747..05e18fb 100644 --- a/commands/transactions/__init__.py +++ b/commands/transactions/__init__.py @@ -3,10 +3,10 @@ Transaction command package for Discord Bot v2.0 Contains transaction management commands for league operations. """ + import logging from typing import List, Tuple, Type -import discord from discord.ext import commands from .management import TransactionCommands @@ -14,13 +14,13 @@ from .dropadd import DropAddCommands from .trade import TradeCommands from .ilmove import ILMoveCommands -logger = logging.getLogger(f'{__name__}.setup_transactions') +logger = logging.getLogger(f"{__name__}.setup_transactions") async def setup_transactions(bot: commands.Bot) -> Tuple[int, int, List[str]]: """ Set up transaction command modules. - + Returns: Tuple of (successful_loads, failed_loads, failed_modules) """ @@ -30,27 +30,33 @@ async def setup_transactions(bot: commands.Bot) -> Tuple[int, int, List[str]]: ("TradeCommands", TradeCommands), ("ILMoveCommands", ILMoveCommands), ] - + successful = 0 failed = 0 failed_modules = [] - + for cog_name, cog_class in transaction_cogs: try: await bot.add_cog(cog_class(bot)) logger.info(f"✅ Loaded transaction command module: {cog_name}") successful += 1 except Exception as e: - logger.error(f"❌ Failed to load transaction command module {cog_name}: {e}") + logger.error( + f"❌ Failed to load transaction command module {cog_name}: {e}" + ) failed += 1 failed_modules.append(cog_name) - + # Log summary if failed == 0: - logger.info(f"🎉 All {successful} transaction command modules loaded successfully") + logger.info( + f"🎉 All {successful} transaction command modules loaded successfully" + ) else: - logger.warning(f"⚠️ Transaction commands loaded with issues: {successful} successful, {failed} failed") + logger.warning( + f"⚠️ Transaction commands loaded with issues: {successful} successful, {failed} failed" + ) if failed_modules: logger.warning(f"Failed modules: {', '.join(failed_modules)}") - - return successful, failed, failed_modules \ No newline at end of file + + return successful, failed, failed_modules diff --git a/commands/transactions/dropadd.py b/commands/transactions/dropadd.py index 4fa5df9..3225949 100644 --- a/commands/transactions/dropadd.py +++ b/commands/transactions/dropadd.py @@ -3,7 +3,7 @@ Modern /dropadd Command Interactive transaction builder with real-time validation and elegant UX. """ -from typing import Optional, List +from typing import Optional import discord from discord.ext import commands @@ -24,7 +24,6 @@ from services.transaction_builder import ( clear_transaction_builder ) from services.player_service import player_service -from services.team_service import team_service from views.transaction_embed import TransactionEmbedView, create_transaction_embed diff --git a/commands/transactions/ilmove.py b/commands/transactions/ilmove.py index f801906..eb5821f 100644 --- a/commands/transactions/ilmove.py +++ b/commands/transactions/ilmove.py @@ -29,7 +29,6 @@ from services.transaction_builder import ( ) from services.player_service import player_service from services.team_service import team_service -from services.league_service import league_service from views.transaction_embed import TransactionEmbedView, create_transaction_embed diff --git a/commands/transactions/trade.py b/commands/transactions/trade.py index 52e0faa..f937152 100644 --- a/commands/transactions/trade.py +++ b/commands/transactions/trade.py @@ -16,7 +16,6 @@ from utils.autocomplete import player_autocomplete, major_league_team_autocomple from utils.team_utils import validate_user_has_team, get_team_by_abbrev_with_validation from services.trade_builder import ( - TradeBuilder, get_trade_builder, get_trade_builder_by_team, clear_trade_builder, diff --git a/commands/transactions/trade_channel_tracker.py b/commands/transactions/trade_channel_tracker.py index 58b6ff3..f3d34c3 100644 --- a/commands/transactions/trade_channel_tracker.py +++ b/commands/transactions/trade_channel_tracker.py @@ -4,7 +4,6 @@ Trade Channel Tracker Provides persistent tracking of bot-created trade discussion channels using JSON file storage. """ import json -import logging from datetime import datetime, UTC from pathlib import Path from typing import Dict, List, Optional, Any diff --git a/commands/utilities/charts.py b/commands/utilities/charts.py index e3b1b9c..d19b487 100644 --- a/commands/utilities/charts.py +++ b/commands/utilities/charts.py @@ -12,7 +12,7 @@ from typing import List, Optional from config import get_config from utils.decorators import logged_command from utils.logging import get_contextual_logger, set_discord_context -from services.chart_service import get_chart_service, Chart +from services.chart_service import get_chart_service from views.embeds import EmbedTemplate, EmbedColors from exceptions import BotException diff --git a/commands/utilities/weather.py b/commands/utilities/weather.py index ba3bbbe..38a75e3 100644 --- a/commands/utilities/weather.py +++ b/commands/utilities/weather.py @@ -4,7 +4,7 @@ Weather command for Discord Bot v2.0 Provides ballpark weather checks with dice rolls for gameplay. """ import random -from typing import Optional, Tuple +from typing import Optional import discord from discord.ext import commands diff --git a/models/custom_command.py b/models/custom_command.py index ac8602b..a65b3b2 100644 --- a/models/custom_command.py +++ b/models/custom_command.py @@ -4,7 +4,7 @@ Custom Command models for Discord Bot v2.0 Modern Pydantic models for the custom command system with full type safety. """ from datetime import datetime -from typing import Optional, Dict, Any +from typing import Optional import re from pydantic import BaseModel, Field, field_validator diff --git a/models/draft_list.py b/models/draft_list.py index 709675e..a7dc4ca 100644 --- a/models/draft_list.py +++ b/models/draft_list.py @@ -3,7 +3,7 @@ Draft preference list model Represents team draft board rankings and preferences. """ -from typing import Optional, Dict, Any +from typing import Dict, Any from pydantic import Field from models.base import SBABaseModel diff --git a/models/draft_pick.py b/models/draft_pick.py index 948fb3a..3529eab 100644 --- a/models/draft_pick.py +++ b/models/draft_pick.py @@ -9,8 +9,8 @@ When the API short_output=false, these fields contain full Team/Player objects. When short_output=true (or default), they contain integer IDs. We use Pydantic aliases to handle both cases. """ -from typing import Optional, Any, Dict, Union -from pydantic import Field, field_validator, model_validator +from typing import Optional, Any, Dict +from pydantic import Field from models.base import SBABaseModel from models.team import Team diff --git a/models/injury.py b/models/injury.py index 53455e4..f8194cd 100644 --- a/models/injury.py +++ b/models/injury.py @@ -3,7 +3,7 @@ Injury model for tracking player injuries Represents an injury record with game timeline and status information. """ -from typing import Optional, Any, Dict +from typing import Any from pydantic import Field, model_validator from models.base import SBABaseModel diff --git a/models/trade.py b/models/trade.py index 1bce54e..d250c34 100644 --- a/models/trade.py +++ b/models/trade.py @@ -3,11 +3,10 @@ Trade-specific data models for multi-team transactions. Extends the base transaction system to support trades between multiple teams. """ -from typing import List, Optional, Dict, Set +from typing import List, Optional from dataclasses import dataclass from enum import Enum -from models.player import Player from models.team import Team, RosterType from services.transaction_builder import TransactionMove diff --git a/models/transaction.py b/models/transaction.py index e50defd..e0c6fea 100644 --- a/models/transaction.py +++ b/models/transaction.py @@ -3,7 +3,7 @@ Transaction models for SBA transaction management Represents transactions and player moves based on actual API structure. """ -from typing import Optional, List +from typing import List from pydantic import Field from models.base import SBABaseModel diff --git a/services/base_service.py b/services/base_service.py index e1677b3..e919e6b 100644 --- a/services/base_service.py +++ b/services/base_service.py @@ -5,7 +5,6 @@ Provides common CRUD operations and error handling for all data services. """ import logging import hashlib -import json from typing import Optional, Type, TypeVar, Generic, Dict, Any, List, Tuple from api.client import get_global_client, APIClient diff --git a/services/custom_commands_service.py b/services/custom_commands_service.py index 13c5b2a..ff8cf11 100644 --- a/services/custom_commands_service.py +++ b/services/custom_commands_service.py @@ -3,10 +3,9 @@ Custom Commands Service for Discord Bot v2.0 Modern async service layer for managing custom commands with full type safety. """ -import asyncio import math from datetime import datetime, timedelta -from typing import Optional, List, Dict, Any, Tuple +from typing import Optional, List, Any, Tuple from utils.logging import get_contextual_logger from models.custom_command import ( diff --git a/services/decision_service.py b/services/decision_service.py index cf6a2cc..e101d0e 100644 --- a/services/decision_service.py +++ b/services/decision_service.py @@ -7,7 +7,6 @@ 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 diff --git a/services/draft_list_service.py b/services/draft_list_service.py index 47f2dd1..2a7017d 100644 --- a/services/draft_list_service.py +++ b/services/draft_list_service.py @@ -8,7 +8,6 @@ from typing import Optional, List from services.base_service import BaseService from models.draft_list import DraftList -from exceptions import APIException logger = logging.getLogger(f'{__name__}.DraftListService') @@ -46,7 +45,6 @@ class DraftListService(BaseService[DraftList]): Returns: Tuple of (items list, total count) """ - from typing import Any, Dict, List, Tuple if isinstance(data, list): return data, len(data) diff --git a/services/draft_pick_service.py b/services/draft_pick_service.py index fecd61c..8327855 100644 --- a/services/draft_pick_service.py +++ b/services/draft_pick_service.py @@ -8,7 +8,6 @@ from typing import Optional, List from services.base_service import BaseService from models.draft_pick import DraftPick -from exceptions import APIException logger = logging.getLogger(f'{__name__}.DraftPickService') diff --git a/services/draft_service.py b/services/draft_service.py index 991334a..e8450cd 100644 --- a/services/draft_service.py +++ b/services/draft_service.py @@ -9,7 +9,6 @@ from datetime import datetime, timedelta from services.base_service import BaseService from models.draft_data import DraftData -from exceptions import APIException logger = logging.getLogger(f'{__name__}.DraftService') diff --git a/services/draft_sheet_service.py b/services/draft_sheet_service.py index ba8d320..fa28f66 100644 --- a/services/draft_sheet_service.py +++ b/services/draft_sheet_service.py @@ -8,7 +8,6 @@ import asyncio from typing import List, Optional, Tuple from config import get_config -from exceptions import SheetsException from services.sheets_service import SheetsService from utils.logging import get_contextual_logger diff --git a/services/help_commands_service.py b/services/help_commands_service.py index 1543ed0..473adb6 100644 --- a/services/help_commands_service.py +++ b/services/help_commands_service.py @@ -5,7 +5,6 @@ Modern async service layer for managing help commands with full type safety. Allows admins and help editors to create custom help topics for league documentation, resources, FAQs, links, and guides. """ -import math from typing import Optional, List from utils.logging import get_contextual_logger diff --git a/services/injury_service.py b/services/injury_service.py index 1b379aa..555a7a5 100644 --- a/services/injury_service.py +++ b/services/injury_service.py @@ -8,7 +8,6 @@ from typing import Optional, List from services.base_service import BaseService from models.injury import Injury -from exceptions import APIException logger = logging.getLogger(f'{__name__}.InjuryService') diff --git a/services/roster_service.py b/services/roster_service.py index de03731..9ee7648 100644 --- a/services/roster_service.py +++ b/services/roster_service.py @@ -7,7 +7,6 @@ Handles roster operations and validation. import logging from typing import Optional, List, Dict -from services.base_service import BaseService from models.roster import TeamRoster from models.player import Player from models.transaction import RosterValidation diff --git a/services/schedule_service.py b/services/schedule_service.py index a8bf406..c537239 100644 --- a/services/schedule_service.py +++ b/services/schedule_service.py @@ -6,9 +6,7 @@ Handles game schedule and results retrieval and processing. import logging from typing import Optional, List, Dict, Tuple -from services.base_service import BaseService from models.game import Game -from exceptions import APIException logger = logging.getLogger(f'{__name__}.ScheduleService') diff --git a/services/scorebug_service.py b/services/scorebug_service.py index 0c6f3f5..cac00ee 100644 --- a/services/scorebug_service.py +++ b/services/scorebug_service.py @@ -4,7 +4,7 @@ Scorebug Service Handles reading live game data from Google Sheets scorecards for real-time score displays. """ import asyncio -from typing import Dict, List, Any, Optional +from typing import Dict, Any, Optional import pygsheets from utils.logging import get_contextual_logger diff --git a/services/standings_service.py b/services/standings_service.py index 1d71bf2..af3b164 100644 --- a/services/standings_service.py +++ b/services/standings_service.py @@ -6,7 +6,6 @@ Handles team standings retrieval and processing. import logging from typing import Optional, List, Dict -from services.base_service import BaseService from models.standings import TeamStandings from exceptions import APIException diff --git a/services/stats_service.py b/services/stats_service.py index 54eb38d..323c956 100644 --- a/services/stats_service.py +++ b/services/stats_service.py @@ -4,12 +4,10 @@ Statistics service for Discord Bot v2.0 Handles batting and pitching statistics retrieval and processing. """ import logging -from typing import Optional, List +from typing import Optional -from services.base_service import BaseService from models.batting_stats import BattingStats from models.pitching_stats import PitchingStats -from exceptions import APIException logger = logging.getLogger(f'{__name__}.StatsService') diff --git a/services/trade_builder.py b/services/trade_builder.py index 6550ffe..879959f 100644 --- a/services/trade_builder.py +++ b/services/trade_builder.py @@ -4,18 +4,16 @@ Trade Builder Service Extends the TransactionBuilder to support multi-team trades and player exchanges. """ import logging -from typing import Dict, List, Optional, Set, Tuple +from typing import Dict, List, Optional, Set from datetime import datetime, timezone import uuid from config import get_config -from models.trade import Trade, TradeParticipant, TradeMove, TradeStatus +from models.trade import Trade, TradeMove, TradeStatus from models.team import Team, RosterType from models.player import Player from services.transaction_builder import TransactionBuilder, RosterValidationResult, TransactionMove from services.team_service import team_service -from services.roster_service import roster_service -from services.league_service import league_service logger = logging.getLogger(f'{__name__}.TradeBuilder') diff --git a/services/transaction_builder.py b/services/transaction_builder.py index 2993f7c..ffa4927 100644 --- a/services/transaction_builder.py +++ b/services/transaction_builder.py @@ -4,8 +4,7 @@ Transaction Builder Service Handles the complex logic for building multi-move transactions interactively. """ import logging -from typing import Dict, List, Optional, Tuple, Set -from enum import Enum +from typing import Dict, List, Optional from dataclasses import dataclass from datetime import datetime, timezone @@ -14,8 +13,6 @@ from models.transaction import Transaction from models.team import Team from models.player import Player from models.roster import TeamRoster -from services.player_service import player_service -from services.team_service import team_service from services.roster_service import roster_service from services.transaction_service import transaction_service from services.league_service import league_service diff --git a/services/transaction_service.py b/services/transaction_service.py index 4d103d8..44e2a01 100644 --- a/services/transaction_service.py +++ b/services/transaction_service.py @@ -4,12 +4,11 @@ Transaction service for Discord Bot v2.0 Handles transaction CRUD operations and business logic. """ import logging -from typing import Optional, List, Tuple +from typing import Optional, List from datetime import datetime, UTC from services.base_service import BaseService from models.transaction import Transaction, RosterValidation -from models.roster import TeamRoster from exceptions import APIException logger = logging.getLogger(f'{__name__}.TransactionService') diff --git a/tasks/custom_command_cleanup.py b/tasks/custom_command_cleanup.py index 87af4ec..78734e1 100644 --- a/tasks/custom_command_cleanup.py +++ b/tasks/custom_command_cleanup.py @@ -5,7 +5,7 @@ Modern automated cleanup system with better notifications and logging. """ import asyncio from datetime import datetime, timedelta, UTC -from typing import Dict, List, Optional +from typing import Dict, List import discord from discord.ext import commands, tasks @@ -13,7 +13,7 @@ from discord.ext import commands, tasks from services.custom_commands_service import custom_commands_service from models.custom_command import CustomCommand from utils.logging import get_contextual_logger -from views.embeds import EmbedTemplate, EmbedColors +from views.embeds import EmbedTemplate from config import get_config diff --git a/tasks/draft_monitor.py b/tasks/draft_monitor.py index aa5e7a1..1edc4af 100644 --- a/tasks/draft_monitor.py +++ b/tasks/draft_monitor.py @@ -4,9 +4,7 @@ Draft Monitor Task for Discord Bot v2.0 Automated background task for draft timer monitoring, warnings, and auto-draft. Self-terminates when draft timer is disabled to conserve resources. """ -import asyncio from datetime import datetime -from typing import Optional import discord from discord.ext import commands, tasks @@ -15,12 +13,9 @@ from services.draft_service import draft_service from services.draft_pick_service import draft_pick_service from services.draft_list_service import draft_list_service from services.draft_sheet_service import get_draft_sheet_service -from services.player_service import player_service -from services.team_service import team_service from services.roster_service import roster_service from utils.logging import get_contextual_logger from utils.helpers import get_team_salary_cap -from views.embeds import EmbedTemplate, EmbedColors from views.draft_views import create_on_clock_announcement_embed from config import get_config diff --git a/tasks/live_scorebug_tracker.py b/tasks/live_scorebug_tracker.py index 2885434..5f76269 100644 --- a/tasks/live_scorebug_tracker.py +++ b/tasks/live_scorebug_tracker.py @@ -4,7 +4,7 @@ Live Scorebug Tracker Background task that monitors published scorecards and updates live score displays. """ import asyncio -from typing import List, Optional +from typing import List import discord from discord.ext import tasks, commands @@ -16,7 +16,6 @@ from services.scorebug_service import ScorebugData, ScorebugService from services.team_service import team_service from commands.gameplay.scorecard_tracker import ScorecardTracker from commands.voice.tracker import VoiceChannelTracker -from views.embeds import EmbedTemplate, EmbedColors from config import get_config from exceptions import SheetsException diff --git a/tests/conftest.py b/tests/conftest.py index 3083dd0..c389d9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ Pytest configuration and fixtures for Discord Bot v2.0 tests. This file provides test isolation and shared fixtures. """ + import asyncio import os import pytest @@ -27,12 +28,14 @@ def reset_singleton_state(): # Reset transaction builder caches try: from services.transaction_builder import _transaction_builders + _transaction_builders.clear() except ImportError: pass try: from services.trade_builder import _trade_builders, _team_to_trade_key + _trade_builders.clear() _team_to_trade_key.clear() except ImportError: @@ -40,8 +43,9 @@ def reset_singleton_state(): # Reset config singleton to ensure clean state try: - from config import _config + from config import _config # noqa: F401 import config as cfg + cfg._config = None except (ImportError, AttributeError): pass diff --git a/tests/factories.py b/tests/factories.py index 84b6658..8cdbd89 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -5,10 +5,10 @@ Provides factory functions to create test instances of models with sensible defa This eliminates the need for ad-hoc fixture creation and makes tests resilient to model changes. """ -from typing import Optional, Dict, Any +from typing import Optional from models.player import Player -from models.team import Team, RosterType +from models.team import Team from models.transaction import Transaction from models.game import Game from models.current import Current diff --git a/tests/test_api_client.py b/tests/test_api_client.py index c16693b..14edf0f 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -3,7 +3,6 @@ API client tests using aioresponses for clean HTTP mocking """ import pytest import asyncio -import aiohttp from unittest.mock import MagicMock, patch from aioresponses import aioresponses diff --git a/tests/test_commands_charts.py b/tests/test_commands_charts.py index bc3c2e8..0cf0648 100644 --- a/tests/test_commands_charts.py +++ b/tests/test_commands_charts.py @@ -6,13 +6,12 @@ import json import tempfile from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch -from discord import app_commands from commands.utilities.charts import ( ChartCommands, ChartManageGroup, ChartCategoryGroup, - chart_autocomplete, category_autocomplete + chart_autocomplete ) -from services.chart_service import ChartService, Chart, get_chart_service +from services.chart_service import ChartService, Chart from exceptions import BotException diff --git a/tests/test_commands_dice.py b/tests/test_commands_dice.py index b8701a9..839bf34 100644 --- a/tests/test_commands_dice.py +++ b/tests/test_commands_dice.py @@ -9,7 +9,7 @@ import discord from discord.ext import commands from commands.dice.rolls import DiceRollCommands -from utils.dice_utils import DiceRoll, parse_and_roll_multiple_dice, parse_and_roll_single_dice +from utils.dice_utils import DiceRoll, parse_and_roll_multiple_dice class TestDiceRollCommands: diff --git a/tests/test_commands_dropadd.py b/tests/test_commands_dropadd.py index ce5376e..d1f7e37 100644 --- a/tests/test_commands_dropadd.py +++ b/tests/test_commands_dropadd.py @@ -6,14 +6,10 @@ Validates the Discord command interface, autocomplete, and user interactions. import pytest from unittest.mock import AsyncMock, MagicMock, patch -import discord -from discord import app_commands from commands.transactions.dropadd import DropAddCommands from services.transaction_builder import TransactionBuilder from models.team import RosterType -from models.team import Team -from models.player import Player from tests.factories import PlayerFactory, TeamFactory diff --git a/tests/test_commands_profile_images.py b/tests/test_commands_profile_images.py index 4d3b905..9a12f08 100644 --- a/tests/test_commands_profile_images.py +++ b/tests/test_commands_profile_images.py @@ -15,8 +15,6 @@ from commands.profile.images import ( can_edit_player_image, ImageCommands ) -from models.player import Player -from models.team import Team from tests.factories import PlayerFactory, TeamFactory diff --git a/tests/test_commands_soak.py b/tests/test_commands_soak.py index 71dcaef..445e01a 100644 --- a/tests/test_commands_soak.py +++ b/tests/test_commands_soak.py @@ -7,12 +7,11 @@ Tests cover: - Message listener (detection logic) - Info command (response formatting) """ + import pytest import json import re -from datetime import datetime, timedelta, UTC -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch, mock_open +from datetime import datetime, UTC from aioresponses import aioresponses # Import modules to test @@ -21,15 +20,16 @@ from commands.soak.giphy_service import ( get_random_phrase_for_tier, get_tier_description, get_disappointment_gif, - DISAPPOINTMENT_TIERS ) +from services.giphy_service import DISAPPOINTMENT_TIERS from commands.soak.tracker import SoakTracker + # Listener uses simple string matching: ' soak' in msg_text.lower() # Define helper function that mimics the listener's detection logic def soak_detected(text: str) -> bool: """Check if soak mention is detected using listener's logic.""" - return ' soak' in text.lower() + return " soak" in text.lower() class TestGiphyService: @@ -38,78 +38,78 @@ class TestGiphyService: def test_tier_determination_first_ever(self): """Test tier determination for first ever soak.""" tier = get_tier_for_seconds(None) - assert tier == 'first_ever' + assert tier == "first_ever" def test_tier_determination_maximum_disappointment(self): """Test tier 1: 0-30 minutes (maximum disappointment).""" # 15 minutes tier = get_tier_for_seconds(900) - assert tier == 'tier_1' + assert tier == "tier_1" # 30 minutes (boundary) tier = get_tier_for_seconds(1800) - assert tier == 'tier_1' + assert tier == "tier_1" def test_tier_determination_severe_disappointment(self): """Test tier 2: 30min-2hrs (severe disappointment).""" # 1 hour tier = get_tier_for_seconds(3600) - assert tier == 'tier_2' + assert tier == "tier_2" # 2 hours (boundary) tier = get_tier_for_seconds(7200) - assert tier == 'tier_2' + assert tier == "tier_2" def test_tier_determination_strong_disappointment(self): """Test tier 3: 2-6 hours (strong disappointment).""" # 4 hours tier = get_tier_for_seconds(14400) - assert tier == 'tier_3' + assert tier == "tier_3" # 6 hours (boundary) tier = get_tier_for_seconds(21600) - assert tier == 'tier_3' + assert tier == "tier_3" def test_tier_determination_moderate_disappointment(self): """Test tier 4: 6-24 hours (moderate disappointment).""" # 12 hours tier = get_tier_for_seconds(43200) - assert tier == 'tier_4' + assert tier == "tier_4" # 24 hours (boundary) tier = get_tier_for_seconds(86400) - assert tier == 'tier_4' + assert tier == "tier_4" def test_tier_determination_mild_disappointment(self): """Test tier 5: 1-7 days (mild disappointment).""" # 3 days tier = get_tier_for_seconds(259200) - assert tier == 'tier_5' + assert tier == "tier_5" # 7 days (boundary) tier = get_tier_for_seconds(604800) - assert tier == 'tier_5' + assert tier == "tier_5" def test_tier_determination_minimal_disappointment(self): """Test tier 6: 7+ days (minimal disappointment).""" # 10 days tier = get_tier_for_seconds(864000) - assert tier == 'tier_6' + assert tier == "tier_6" # 30 days tier = get_tier_for_seconds(2592000) - assert tier == 'tier_6' + assert tier == "tier_6" def test_random_phrase_selection(self): """Test that random phrase selection returns valid phrases.""" for tier_key in DISAPPOINTMENT_TIERS.keys(): phrase = get_random_phrase_for_tier(tier_key) - assert phrase in DISAPPOINTMENT_TIERS[tier_key]['phrases'] + assert phrase in DISAPPOINTMENT_TIERS[tier_key]["phrases"] def test_tier_description_retrieval(self): """Test tier description retrieval.""" - assert get_tier_description('tier_1') == "Maximum Disappointment" - assert get_tier_description('first_ever') == "The Beginning" + assert get_tier_description("tier_1") == "Maximum Disappointment" + assert get_tier_description("first_ever") == "The Beginning" @pytest.mark.asyncio async def test_get_disappointment_gif_success(self): @@ -122,22 +122,22 @@ class TestGiphyService: # Mock successful Giphy response with correct response structure # The service looks for data.images.original.url, not data.url m.get( - re.compile(r'https://api\.giphy\.com/v1/gifs/translate\?.*'), + re.compile(r"https://api\.giphy\.com/v1/gifs/translate\?.*"), payload={ - 'data': { - 'images': { - 'original': { - 'url': 'https://media.giphy.com/media/test123/giphy.gif' + "data": { + "images": { + "original": { + "url": "https://media.giphy.com/media/test123/giphy.gif" } }, - 'title': 'Disappointed Reaction' + "title": "Disappointed Reaction", } }, - status=200 + status=200, ) - gif_url = await get_disappointment_gif('tier_1') - assert gif_url == 'https://media.giphy.com/media/test123/giphy.gif' + gif_url = await get_disappointment_gif("tier_1") + assert gif_url == "https://media.giphy.com/media/test123/giphy.gif" @pytest.mark.asyncio async def test_get_disappointment_gif_filters_trump(self): @@ -151,37 +151,37 @@ class TestGiphyService: # First response is Trump GIF (should be filtered) # Uses correct response structure with images.original.url m.get( - re.compile(r'https://api\.giphy\.com/v1/gifs/translate\?.*'), + re.compile(r"https://api\.giphy\.com/v1/gifs/translate\?.*"), payload={ - 'data': { - 'images': { - 'original': { - 'url': 'https://media.giphy.com/media/trump123/giphy.gif' + "data": { + "images": { + "original": { + "url": "https://media.giphy.com/media/trump123/giphy.gif" } }, - 'title': 'Donald Trump Disappointed' + "title": "Donald Trump Disappointed", } }, - status=200 + status=200, ) # Second response is acceptable m.get( - re.compile(r'https://api\.giphy\.com/v1/gifs/translate\?.*'), + re.compile(r"https://api\.giphy\.com/v1/gifs/translate\?.*"), payload={ - 'data': { - 'images': { - 'original': { - 'url': 'https://media.giphy.com/media/good456/giphy.gif' + "data": { + "images": { + "original": { + "url": "https://media.giphy.com/media/good456/giphy.gif" } }, - 'title': 'Disappointed Reaction' + "title": "Disappointed Reaction", } }, - status=200 + status=200, ) - gif_url = await get_disappointment_gif('tier_1') - assert gif_url == 'https://media.giphy.com/media/good456/giphy.gif' + gif_url = await get_disappointment_gif("tier_1") + assert gif_url == "https://media.giphy.com/media/good456/giphy.gif" @pytest.mark.asyncio async def test_get_disappointment_gif_api_failure(self): @@ -189,12 +189,12 @@ class TestGiphyService: with aioresponses() as m: # Mock API failure for all requests m.get( - re.compile(r'https://api\.giphy\.com/v1/gifs/translate\?.*'), + re.compile(r"https://api\.giphy\.com/v1/gifs/translate\?.*"), status=500, - repeat=True + repeat=True, ) - gif_url = await get_disappointment_gif('tier_1') + gif_url = await get_disappointment_gif("tier_1") assert gif_url is None @@ -224,13 +224,13 @@ class TestSoakTracker: "username": "testuser", "display_name": "Test User", "channel_id": "456", - "message_id": "789" + "message_id": "789", }, "total_count": 5, - "history": [] + "history": [], } - with open(temp_tracker_file, 'w') as f: + with open(temp_tracker_file, "w") as f: json.dump(existing_data, f) tracker = SoakTracker(temp_tracker_file) @@ -247,15 +247,15 @@ class TestSoakTracker: username="testuser", display_name="Test User", channel_id=789012, - message_id=345678 + message_id=345678, ) assert tracker.get_soak_count() == 1 last_soak = tracker.get_last_soak() assert last_soak is not None - assert last_soak['user_id'] == '123456' - assert last_soak['username'] == 'testuser' + assert last_soak["user_id"] == "123456" + assert last_soak["username"] == "testuser" def test_record_multiple_soaks(self, temp_tracker_file): """Test recording multiple soaks maintains history.""" @@ -268,7 +268,7 @@ class TestSoakTracker: username=f"user{i}", display_name=f"User {i}", channel_id=100, - message_id=200 + i + message_id=200 + i, ) assert tracker.get_soak_count() == 3 @@ -276,8 +276,8 @@ class TestSoakTracker: history = tracker.get_history() assert len(history) == 3 # History should be newest first - assert history[0]['user_id'] == '2' - assert history[2]['user_id'] == '0' + assert history[0]["user_id"] == "2" + assert history[2]["user_id"] == "0" def test_get_time_since_last_soak(self, temp_tracker_file): """Test time calculation since last soak.""" @@ -292,7 +292,7 @@ class TestSoakTracker: username="test", display_name="Test", channel_id=456, - message_id=789 + message_id=789, ) # Time since should be very small (just recorded) @@ -311,7 +311,7 @@ class TestSoakTracker: username=f"user{i}", display_name=f"User {i}", channel_id=100, - message_id=200 + i + message_id=200 + i, ) history = tracker.get_history(limit=9999) @@ -380,5 +380,9 @@ class TestInfoCommand: channel_id = 987654321 message_id = 111222333 - expected_url = f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}" - assert expected_url == "https://discord.com/channels/123456789/987654321/111222333" + expected_url = ( + f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}" + ) + assert ( + expected_url == "https://discord.com/channels/123456789/987654321/111222333" + ) diff --git a/tests/test_commands_teams_branding.py b/tests/test_commands_teams_branding.py index acb9eec..abf068c 100644 --- a/tests/test_commands_teams_branding.py +++ b/tests/test_commands_teams_branding.py @@ -14,7 +14,6 @@ from commands.teams.branding import ( BrandingCommands ) from models.team import Team -from tests.factories import TeamFactory class TestHexColorValidation: diff --git a/tests/test_commands_voice.py b/tests/test_commands_voice.py index bb1d708..b860e01 100644 --- a/tests/test_commands_voice.py +++ b/tests/test_commands_voice.py @@ -3,8 +3,6 @@ Tests for voice channel commands Validates voice channel creation, cleanup, and migration message functionality. """ -import asyncio -import json import tempfile from datetime import datetime, timedelta, UTC from pathlib import Path diff --git a/tests/test_config.py b/tests/test_config.py index 5258ca0..a90115a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,7 +8,6 @@ import pytest from unittest.mock import patch from config import BotConfig -from exceptions import ConfigurationException class TestBotConfig: diff --git a/tests/test_constants.py b/tests/test_constants.py index e95de44..0026692 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -3,7 +3,6 @@ Tests for application configuration Validates that config values have sensible defaults. """ -import pytest from config import get_config, PITCHER_POSITIONS, POSITION_FIELDERS, ALL_POSITIONS diff --git a/tests/test_dropadd_integration.py b/tests/test_dropadd_integration.py index f81626b..0140e2a 100644 --- a/tests/test_dropadd_integration.py +++ b/tests/test_dropadd_integration.py @@ -6,18 +6,15 @@ Tests complete workflows from command invocation through transaction submission. import pytest from unittest.mock import AsyncMock, MagicMock, patch -from datetime import datetime from commands.transactions.dropadd import DropAddCommands from services.transaction_builder import ( - TransactionBuilder, TransactionMove, get_transaction_builder, clear_transaction_builder, ) from models.team import RosterType -from views.transaction_embed import TransactionEmbedView, SubmitConfirmationModal -from models.team import Team +from views.transaction_embed import SubmitConfirmationModal from models.player import Player from models.roster import TeamRoster from models.transaction import Transaction diff --git a/tests/test_models.py b/tests/test_models.py index 34f734a..bf85351 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,7 +4,6 @@ Tests for SBA data models Validates model creation, validation, and business logic. """ import pytest -from datetime import datetime from models import Team, Player, Current, DraftPick, DraftData, DraftList diff --git a/tests/test_models_trade.py b/tests/test_models_trade.py index d9a1798..2c66419 100644 --- a/tests/test_models_trade.py +++ b/tests/test_models_trade.py @@ -4,7 +4,6 @@ Tests for trade-specific models. Tests the Trade, TradeParticipant, and TradeMove models to ensure proper validation and behavior for multi-team trades. """ -import pytest from unittest.mock import MagicMock from models.trade import Trade, TradeParticipant, TradeMove, TradeStatus diff --git a/tests/test_models_transaction.py b/tests/test_models_transaction.py index 224245b..aa44b75 100644 --- a/tests/test_models_transaction.py +++ b/tests/test_models_transaction.py @@ -5,11 +5,8 @@ Validates transaction model creation, validation, and business logic. """ import pytest import copy -from datetime import datetime from models.transaction import Transaction, RosterValidation -from models.player import Player -from models.team import Team class TestTransaction: diff --git a/tests/test_services_base_service.py b/tests/test_services_base_service.py index ca90b6c..4ef6602 100644 --- a/tests/test_services_base_service.py +++ b/tests/test_services_base_service.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock from services.base_service import BaseService from models.base import SBABaseModel -from exceptions import APIException class MockModel(SBABaseModel): diff --git a/tests/test_services_custom_commands.py b/tests/test_services_custom_commands.py index 5296002..2b9da87 100644 --- a/tests/test_services_custom_commands.py +++ b/tests/test_services_custom_commands.py @@ -4,23 +4,17 @@ Tests for Custom Commands Service in Discord Bot v2.0 Fixed version with proper mocking following established patterns. """ import pytest -import asyncio from datetime import datetime, timedelta, timezone -from unittest.mock import AsyncMock, MagicMock, patch -from typing import List +from unittest.mock import AsyncMock from services.custom_commands_service import ( CustomCommandsService, CustomCommandNotFoundError, - CustomCommandExistsError, - CustomCommandPermissionError + CustomCommandExistsError ) from models.custom_command import ( CustomCommand, - CustomCommandCreator, - CustomCommandSearchFilters, - CustomCommandSearchResult, - CustomCommandStats + CustomCommandCreator ) diff --git a/tests/test_services_draft_sheet.py b/tests/test_services_draft_sheet.py index 96478fe..bc87b0d 100644 --- a/tests/test_services_draft_sheet.py +++ b/tests/test_services_draft_sheet.py @@ -5,8 +5,7 @@ Tests the Google Sheets integration for draft pick tracking. Uses mocked pygsheets to avoid actual API calls. """ import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from typing import Tuple, List +from unittest.mock import MagicMock, patch from services.draft_sheet_service import DraftSheetService, get_draft_sheet_service diff --git a/tests/test_services_help_commands.py b/tests/test_services_help_commands.py index 849c3d7..34eb84b 100644 --- a/tests/test_services_help_commands.py +++ b/tests/test_services_help_commands.py @@ -4,7 +4,7 @@ Tests for Help Commands Service in Discord Bot v2.0 Comprehensive tests for help commands CRUD operations and business logic. """ import pytest -from datetime import datetime, timezone, timedelta +from datetime import datetime, timezone from unittest.mock import AsyncMock from services.help_commands_service import ( diff --git a/tests/test_services_injury.py b/tests/test_services_injury.py index 52f7b55..94cb1f2 100644 --- a/tests/test_services_injury.py +++ b/tests/test_services_injury.py @@ -12,7 +12,7 @@ rather than trying to mock HTTP responses, since the service uses BaseService which manages its own client instance. """ import pytest -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock from services.injury_service import InjuryService from models.injury import Injury diff --git a/tests/test_services_league_service.py b/tests/test_services_league_service.py index a5dce40..752fa7f 100644 --- a/tests/test_services_league_service.py +++ b/tests/test_services_league_service.py @@ -10,7 +10,6 @@ from typing import Dict, Any, List from services.league_service import LeagueService, league_service from models.current import Current -from exceptions import APIException class TestLeagueService: diff --git a/tests/test_services_trade_builder.py b/tests/test_services_trade_builder.py index cd60564..dc2f238 100644 --- a/tests/test_services_trade_builder.py +++ b/tests/test_services_trade_builder.py @@ -19,7 +19,7 @@ from services.trade_builder import ( _team_to_trade_key, ) from models.trade import TradeStatus -from models.team import RosterType, Team +from models.team import RosterType from tests.factories import PlayerFactory, TeamFactory diff --git a/tests/test_services_transaction.py b/tests/test_services_transaction.py index 6bb8de3..9c87e53 100644 --- a/tests/test_services_transaction.py +++ b/tests/test_services_transaction.py @@ -4,8 +4,7 @@ Tests for TransactionService Validates transaction service functionality, API interaction, and business logic. """ import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from datetime import datetime +from unittest.mock import AsyncMock, patch from services.transaction_service import TransactionService, transaction_service from models.transaction import Transaction, RosterValidation diff --git a/tests/test_services_transaction_builder.py b/tests/test_services_transaction_builder.py index e33acb0..8b3dbcb 100644 --- a/tests/test_services_transaction_builder.py +++ b/tests/test_services_transaction_builder.py @@ -5,7 +5,6 @@ Validates transaction building, roster validation, and move management. """ import pytest from unittest.mock import AsyncMock, MagicMock, patch -from datetime import datetime from services.transaction_builder import ( TransactionBuilder, @@ -19,7 +18,6 @@ from models.team import Team from models.player import Player from models.roster import TeamRoster from models.transaction import Transaction -from tests.factories import PlayerFactory, TeamFactory class TestTransactionBuilder: diff --git a/tests/test_tasks_custom_command_cleanup.py b/tests/test_tasks_custom_command_cleanup.py index ff62b8d..a241995 100644 --- a/tests/test_tasks_custom_command_cleanup.py +++ b/tests/test_tasks_custom_command_cleanup.py @@ -4,10 +4,7 @@ Tests for Custom Command Cleanup Tasks in Discord Bot v2.0 Fixed version that tests cleanup logic without Discord task infrastructure. """ import pytest -import asyncio from datetime import datetime, timedelta, timezone -from unittest.mock import AsyncMock, MagicMock, Mock, patch -from typing import List from models.custom_command import ( CustomCommand, diff --git a/tests/test_tasks_transaction_freeze.py b/tests/test_tasks_transaction_freeze.py index f3fc032..31f4c20 100644 --- a/tests/test_tasks_transaction_freeze.py +++ b/tests/test_tasks_transaction_freeze.py @@ -9,8 +9,7 @@ Validates the automated weekly freeze system for transactions, including: - Transaction processing """ import pytest -from datetime import datetime, timezone, UTC -from unittest.mock import AsyncMock, MagicMock, Mock, patch, call +from unittest.mock import AsyncMock, MagicMock, patch from typing import List from tasks.transaction_freeze import ( diff --git a/tests/test_transactions_integration.py b/tests/test_transactions_integration.py index c6e8dd9..134009b 100644 --- a/tests/test_transactions_integration.py +++ b/tests/test_transactions_integration.py @@ -9,7 +9,6 @@ import asyncio from models.transaction import Transaction, RosterValidation from models.team import Team -from models.roster import TeamRoster from services.transaction_service import transaction_service from commands.transactions.management import TransactionCommands from tests.factories import TeamFactory diff --git a/tests/test_utils_decorators.py b/tests/test_utils_decorators.py index b7f799c..703a426 100644 --- a/tests/test_utils_decorators.py +++ b/tests/test_utils_decorators.py @@ -2,9 +2,7 @@ Tests for the logging decorator utility """ import pytest -import asyncio -from unittest.mock import AsyncMock, Mock, patch -import discord +from unittest.mock import Mock, patch from utils.decorators import logged_command from utils.logging import get_contextual_logger diff --git a/tests/test_utils_helpers.py b/tests/test_utils_helpers.py index f667e14..c017b08 100644 --- a/tests/test_utils_helpers.py +++ b/tests/test_utils_helpers.py @@ -12,7 +12,6 @@ Why these tests matter: - Proper fallback behavior ensures backwards compatibility """ -import pytest from utils.helpers import ( DEFAULT_SALARY_CAP, SALARY_CAP_TOLERANCE, diff --git a/tests/test_utils_logging.py b/tests/test_utils_logging.py index 851bf52..5b1ba9e 100644 --- a/tests/test_utils_logging.py +++ b/tests/test_utils_logging.py @@ -6,7 +6,6 @@ Tests contextual logging, operation tracing, and Discord context management. import pytest import time from unittest.mock import Mock, patch -from typing import Dict, Any from utils.logging import ( get_contextual_logger, diff --git a/tests/test_utils_scorebug_helpers.py b/tests/test_utils_scorebug_helpers.py index 53c2d0d..0a97848 100644 --- a/tests/test_utils_scorebug_helpers.py +++ b/tests/test_utils_scorebug_helpers.py @@ -4,7 +4,6 @@ Tests for scorebug_helpers utility functions. Tests the create_team_progress_bar function to ensure correct win probability visualization for home and away teams. """ -import pytest from utils.scorebug_helpers import create_team_progress_bar diff --git a/tests/test_views_custom_commands.py b/tests/test_views_custom_commands.py index 81c645e..bca630a 100644 --- a/tests/test_views_custom_commands.py +++ b/tests/test_views_custom_commands.py @@ -4,17 +4,14 @@ Tests for Custom Command Views in Discord Bot v2.0 Fixed version with proper async handling and model validation. """ import pytest -import asyncio from datetime import datetime, timedelta, timezone -from unittest.mock import AsyncMock, MagicMock, Mock, patch -from typing import List +from unittest.mock import AsyncMock, Mock import discord from models.custom_command import ( CustomCommand, - CustomCommandCreator, - CustomCommandSearchResult + CustomCommandCreator ) diff --git a/tests/test_views_injury_modals.py b/tests/test_views_injury_modals.py index 41e3638..ccc3f9a 100644 --- a/tests/test_views_injury_modals.py +++ b/tests/test_views_injury_modals.py @@ -5,13 +5,11 @@ Tests week and game validation for BatterInjuryModal and PitcherRestModal, including regular season and playoff round validation. """ import pytest -from unittest.mock import AsyncMock, MagicMock, Mock, patch, PropertyMock -from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock import discord from views.modals import BatterInjuryModal, PitcherRestModal -from views.embeds import EmbedTemplate from models.player import Player diff --git a/tests/test_views_transaction_embed.py b/tests/test_views_transaction_embed.py index c21e2b4..ad60453 100644 --- a/tests/test_views_transaction_embed.py +++ b/tests/test_views_transaction_embed.py @@ -11,17 +11,14 @@ import discord from views.transaction_embed import ( TransactionEmbedView, RemoveMoveView, - RemoveMoveSelect, SubmitConfirmationModal, create_transaction_embed, ) from services.transaction_builder import ( TransactionBuilder, - TransactionMove, RosterValidationResult, ) -from models.team import Team, RosterType -from models.player import Player +from models.team import Team class TestTransactionEmbedView: diff --git a/utils/autocomplete.py b/utils/autocomplete.py index acce3c8..9b4371c 100644 --- a/utils/autocomplete.py +++ b/utils/autocomplete.py @@ -3,7 +3,7 @@ Autocomplete Utilities Shared autocomplete functions for Discord slash commands. """ -from typing import List, Optional +from typing import List import discord from discord import app_commands diff --git a/utils/helpers.py b/utils/helpers.py index ccdeaba..ec4fd29 100644 --- a/utils/helpers.py +++ b/utils/helpers.py @@ -3,7 +3,6 @@ Helper functions for Discord Bot v2.0 Contains utility functions for salary cap calculations and other common operations. """ -from typing import Union from config import get_config # Get default values from config diff --git a/utils/injury_log.py b/utils/injury_log.py index b671d45..2594c5d 100644 --- a/utils/injury_log.py +++ b/utils/injury_log.py @@ -5,14 +5,13 @@ Provides functions for posting injury information to Discord channels: - #injury-log: Two embeds showing current injuries by team and by return week - #sba-network-news: Individual injury announcements """ -from typing import Optional, Dict, List, Any +from typing import Dict, List from collections import defaultdict import discord from config import get_config from models.player import Player -from models.team import Team from services.injury_service import injury_service from services.team_service import team_service from views.embeds import EmbedTemplate, EmbedColors diff --git a/utils/random_gen.py b/utils/random_gen.py index 699ffea..a9e0af5 100644 --- a/utils/random_gen.py +++ b/utils/random_gen.py @@ -4,7 +4,7 @@ Random content generation utilities for Discord Bot v2.0 Provides fun, random content for bot interactions and responses. """ import random -from typing import List, Optional, Union +from typing import List, Optional from utils.logging import get_contextual_logger logger = get_contextual_logger(__name__) diff --git a/utils/scorebug_helpers.py b/utils/scorebug_helpers.py index 48108d0..325557c 100644 --- a/utils/scorebug_helpers.py +++ b/utils/scorebug_helpers.py @@ -4,7 +4,6 @@ Scorebug Display Helpers Utility functions for formatting and displaying scorebug information. """ import discord -from typing import Optional from views.embeds import EmbedColors diff --git a/utils/transaction_logging.py b/utils/transaction_logging.py index d6eed24..cbb9de7 100644 --- a/utils/transaction_logging.py +++ b/utils/transaction_logging.py @@ -10,7 +10,6 @@ import discord from config import get_config from models.transaction import Transaction from models.team import Team -from models.trade import Trade from services.trade_builder import TradeBuilder from views.embeds import EmbedTemplate, EmbedColors from utils.logging import get_contextual_logger diff --git a/views/base.py b/views/base.py index dab3ef1..7cfac08 100644 --- a/views/base.py +++ b/views/base.py @@ -3,12 +3,10 @@ Base View Classes for Discord Bot v2.0 Provides foundational view components with consistent styling and behavior. """ -import logging -from typing import List, Optional, Any, Callable, Awaitable, Union +from typing import List, Optional, Any, Callable, Awaitable from datetime import datetime, timezone import discord -from discord.ext import commands from utils.logging import get_contextual_logger diff --git a/views/common.py b/views/common.py index f4aee72..58e575e 100644 --- a/views/common.py +++ b/views/common.py @@ -4,17 +4,14 @@ Common Discord View Components for Bot v2.0 Specialized views for frequent use cases including player/team selection, detailed information displays, and interactive menus. """ -from typing import Optional, List, Dict, Any, Callable, Awaitable, Union -import asyncio +from typing import Optional, List, Dict, Any, Callable, Awaitable import discord -from discord.ext import commands from .base import BaseView, PaginationView, SelectMenuView from .embeds import SBAEmbedTemplate, EmbedTemplate, EmbedColors from models.player import Player from models.team import Team -from utils.logging import get_contextual_logger class PlayerSelectionView(SelectMenuView): diff --git a/views/custom_commands.py b/views/custom_commands.py index 924df63..7e3c4ce 100644 --- a/views/custom_commands.py +++ b/views/custom_commands.py @@ -3,15 +3,13 @@ Custom Command Views for Discord Bot v2.0 Interactive views and modals for the modern custom command system. """ -from typing import Optional, List, Callable, Awaitable +from typing import Optional, List import discord -from discord.ext import commands from views.base import BaseView, ConfirmationView, PaginationView from views.embeds import EmbedTemplate, EmbedColors from views.modals import BaseModal from models.custom_command import CustomCommand, CustomCommandSearchResult -from utils.logging import get_contextual_logger from services.custom_commands_service import custom_commands_service from exceptions import BotException diff --git a/views/draft_views.py b/views/draft_views.py index 264deb5..cab021d 100644 --- a/views/draft_views.py +++ b/views/draft_views.py @@ -4,7 +4,6 @@ Draft Views for Discord Bot v2.0 Provides embeds and UI components for draft system. """ from typing import Optional, List -from datetime import datetime import discord @@ -15,7 +14,6 @@ from models.player import Player from models.draft_list import DraftList from views.embeds import EmbedTemplate, EmbedColors from utils.draft_helpers import format_pick_display, get_round_name -from config import get_config async def create_on_the_clock_embed( diff --git a/views/embeds.py b/views/embeds.py index 52e2218..cc825ee 100644 --- a/views/embeds.py +++ b/views/embeds.py @@ -3,7 +3,7 @@ Embed Templates for Discord Bot v2.0 Provides consistent embed styling and templates for common use cases. """ -from typing import Optional, Union, Any, List +from typing import Optional, Union, List from datetime import datetime from dataclasses import dataclass diff --git a/views/help_commands.py b/views/help_commands.py index b836319..7a9cc3a 100644 --- a/views/help_commands.py +++ b/views/help_commands.py @@ -6,12 +6,10 @@ Interactive views and modals for the custom help system. from typing import Optional, List import discord -from views.base import BaseView, ConfirmationView, PaginationView +from views.base import BaseView from views.embeds import EmbedTemplate, EmbedColors from views.modals import BaseModal -from models.help_command import HelpCommand, HelpCommandSearchResult -from utils.logging import get_contextual_logger -from exceptions import BotException +from models.help_command import HelpCommand class HelpCommandCreateModal(BaseModal): diff --git a/views/modals.py b/views/modals.py index f64b815..857c5a8 100644 --- a/views/modals.py +++ b/views/modals.py @@ -7,9 +7,8 @@ from typing import Optional, Callable, Awaitable, Dict, Any, List import re import discord -from discord.ext import commands -from .embeds import EmbedTemplate, EmbedColors +from .embeds import EmbedTemplate from utils.logging import get_contextual_logger @@ -738,7 +737,6 @@ class PitcherRestModal(BaseModal): """Handle pitcher rest input and log injury.""" from services.player_service import player_service from services.injury_service import injury_service - from models.injury import Injury from config import get_config import math diff --git a/views/players.py b/views/players.py index b5bed53..e28debb 100644 --- a/views/players.py +++ b/views/players.py @@ -7,9 +7,7 @@ Interactive Discord UI components for player information display with toggleable from typing import Optional, TYPE_CHECKING import discord -from discord.ext import commands -from utils.logging import get_contextual_logger from views.base import BaseView from views.embeds import EmbedTemplate, EmbedColors from models.team import RosterType diff --git a/views/trade_embed.py b/views/trade_embed.py index cec7182..507299f 100644 --- a/views/trade_embed.py +++ b/views/trade_embed.py @@ -7,7 +7,7 @@ import discord from typing import Optional, List from datetime import datetime, timezone -from services.trade_builder import TradeBuilder, TradeValidationResult +from services.trade_builder import TradeBuilder from models.team import Team, RosterType from views.embeds import EmbedColors, EmbedTemplate diff --git a/views/transaction_embed.py b/views/transaction_embed.py index 69a12fb..6af27c0 100644 --- a/views/transaction_embed.py +++ b/views/transaction_embed.py @@ -5,12 +5,9 @@ Handles the Discord embed and button interfaces for the transaction builder. """ import discord -from typing import Optional, List -from datetime import datetime from services.transaction_builder import ( TransactionBuilder, - RosterValidationResult, clear_transaction_builder, ) from services.league_service import league_service From 9cd577cba1ee6096865d73e34b7abe4266796517 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Feb 2026 11:48:16 -0600 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20batch=20quick-wins=20=E2=80=94=204?= =?UTF-8?q?=20issues=20resolved=20(closes=20#37,=20#27,=20#25,=20#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #37: Fix stale comment in transaction_freeze.py referencing wrong moveid format - #27: Change config.testing default from True to False (was masking prod behavior) - #25: Replace deprecated asyncio.get_event_loop() with get_running_loop() - #38: Replace naive datetime.now() with timezone-aware datetime.now(UTC) across 7 source files and 4 test files to prevent subtle timezone bugs Co-Authored-By: Claude Opus 4.6 --- commands/draft/picks.py | 164 +++---- config.py | 2 +- models/custom_command.py | 155 +++--- models/draft_data.py | 25 +- models/help_command.py | 64 ++- services/custom_commands_service.py | 699 ++++++++++++++-------------- services/draft_service.py | 87 ++-- services/draft_sheet_service.py | 73 ++- services/scorebug_service.py | 256 ++++++---- services/sheets_service.py | 251 +++++----- tasks/draft_monitor.py | 119 +++-- tasks/transaction_freeze.py | 6 +- tests/test_config.py | 341 ++++++++------ tests/test_models_custom_command.py | 177 +++---- tests/test_models_help_command.py | 323 ++++++------- tests/test_services_draft.py | 554 +++++++++++----------- 16 files changed, 1785 insertions(+), 1511 deletions(-) diff --git a/commands/draft/picks.py b/commands/draft/picks.py index d24c12e..90cbe3c 100644 --- a/commands/draft/picks.py +++ b/commands/draft/picks.py @@ -3,10 +3,11 @@ Draft Pick Commands Implements slash commands for making draft picks with global lock protection. """ + import asyncio import re from typing import List, Optional -from datetime import datetime +from datetime import UTC, datetime import discord from discord.ext import commands @@ -27,7 +28,7 @@ from views.draft_views import ( create_player_draft_card, create_pick_illegal_embed, create_pick_success_embed, - create_on_clock_announcement_embed + create_on_clock_announcement_embed, ) @@ -53,7 +54,7 @@ def _parse_player_name(raw_input: str) -> str: # Pattern: "Player Name (POS) - X.XX sWAR" # Position can be letters or numbers (e.g., SS, RP, 1B, 2B, 3B, OF) # Extract just the player name before the opening parenthesis - match = re.match(r'^(.+?)\s*\([A-Z0-9]+\)\s*-\s*[\d.]+\s*sWAR$', raw_input) + match = re.match(r"^(.+?)\s*\([A-Z0-9]+\)\s*-\s*[\d.]+\s*sWAR$", raw_input) if match: return match.group(1).strip() @@ -73,9 +74,7 @@ async def fa_player_autocomplete( config = get_config() # Search for FA players only players = await player_service.search_players( - current, - limit=25, - season=config.sba_season + current, limit=25, season=config.sba_season ) # Filter to FA team @@ -84,7 +83,7 @@ async def fa_player_autocomplete( return [ discord.app_commands.Choice( name=f"{p.name} ({p.primary_position}) - {p.wara:.2f} sWAR", - value=p.name + value=p.name, ) for p in fa_players[:25] ] @@ -98,7 +97,7 @@ class DraftPicksCog(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.DraftPicksCog') + self.logger = get_contextual_logger(f"{__name__}.DraftPicksCog") # GLOBAL PICK LOCK (local only - not in database) self.pick_lock = asyncio.Lock() @@ -107,7 +106,7 @@ class DraftPicksCog(commands.Cog): @discord.app_commands.command( name="draft", - description="Make a draft pick (autocomplete shows FA players only)" + description="Make a draft pick (autocomplete shows FA players only)", ) @discord.app_commands.describe( player="Player name to draft (autocomplete shows available FA players)" @@ -116,18 +115,14 @@ class DraftPicksCog(commands.Cog): @requires_draft_period @requires_team() @logged_command("/draft") - async def draft_pick( - self, - interaction: discord.Interaction, - player: str - ): + async def draft_pick(self, interaction: discord.Interaction, player: str): """Make a draft pick with global lock protection.""" await interaction.response.defer() # Check if lock is held if self.pick_lock.locked(): if self.lock_acquired_at: - time_held = (datetime.now() - self.lock_acquired_at).total_seconds() + time_held = (datetime.now(UTC) - self.lock_acquired_at).total_seconds() if time_held > 30: # STALE LOCK: Auto-override after 30 seconds @@ -140,14 +135,14 @@ class DraftPicksCog(commands.Cog): embed = await create_pick_illegal_embed( "Pick In Progress", f"Another manager is currently making a pick. " - f"Please wait approximately {30 - int(time_held)} seconds." + f"Please wait approximately {30 - int(time_held)} seconds.", ) await interaction.followup.send(embed=embed) return # Acquire global lock async with self.pick_lock: - self.lock_acquired_at = datetime.now() + self.lock_acquired_at = datetime.now(UTC) self.lock_acquired_by = interaction.user.id try: @@ -157,9 +152,7 @@ class DraftPicksCog(commands.Cog): self.lock_acquired_by = None async def _process_draft_pick( - self, - interaction: discord.Interaction, - player_name: str + self, interaction: discord.Interaction, player_name: str ): """ Process draft pick with validation. @@ -176,14 +169,12 @@ class DraftPicksCog(commands.Cog): # Get user's team (CACHED via @cached_single_item) team = await team_service.get_team_by_owner( - interaction.user.id, - config.sba_season + interaction.user.id, config.sba_season ) if not team: embed = await create_pick_illegal_embed( - "Not a GM", - "You are not registered as a team owner." + "Not a GM", "You are not registered as a team owner." ) await interaction.followup.send(embed=embed) return @@ -192,8 +183,7 @@ class DraftPicksCog(commands.Cog): draft_data = await draft_service.get_draft_data() if not draft_data: embed = await create_pick_illegal_embed( - "Draft Not Found", - "Could not retrieve draft configuration." + "Draft Not Found", "Could not retrieve draft configuration." ) await interaction.followup.send(embed=embed) return @@ -202,21 +192,19 @@ class DraftPicksCog(commands.Cog): if draft_data.paused: embed = await create_pick_illegal_embed( "Draft Paused", - "The draft is currently paused. Please wait for an administrator to resume." + "The draft is currently paused. Please wait for an administrator to resume.", ) await interaction.followup.send(embed=embed) return # Get current pick current_pick = await draft_pick_service.get_pick( - config.sba_season, - draft_data.currentpick + config.sba_season, draft_data.currentpick ) if not current_pick or not current_pick.owner: embed = await create_pick_illegal_embed( - "Invalid Pick", - f"Could not retrieve pick #{draft_data.currentpick}." + "Invalid Pick", f"Could not retrieve pick #{draft_data.currentpick}." ) await interaction.followup.send(embed=embed) return @@ -227,16 +215,14 @@ class DraftPicksCog(commands.Cog): if current_pick.owner.id != team.id: # Not on the clock - check for skipped picks skipped_picks = await draft_pick_service.get_skipped_picks_for_team( - config.sba_season, - team.id, - draft_data.currentpick + config.sba_season, team.id, draft_data.currentpick ) if not skipped_picks: # No skipped picks - can't draft embed = await create_pick_illegal_embed( "Not Your Turn", - f"{current_pick.owner.sname} is on the clock for {format_pick_display(current_pick.overall)}." + f"{current_pick.owner.sname} is on the clock for {format_pick_display(current_pick.overall)}.", ) await interaction.followup.send(embed=embed) return @@ -249,12 +235,13 @@ class DraftPicksCog(commands.Cog): ) # Get player - players = await player_service.get_players_by_name(player_name, config.sba_season) + players = await player_service.get_players_by_name( + player_name, config.sba_season + ) if not players: embed = await create_pick_illegal_embed( - "Player Not Found", - f"Could not find player '{player_name}'." + "Player Not Found", f"Could not find player '{player_name}'." ) await interaction.followup.send(embed=embed) return @@ -264,55 +251,52 @@ class DraftPicksCog(commands.Cog): # Validate player is FA if player_obj.team_id != config.free_agent_team_id: embed = await create_pick_illegal_embed( - "Player Not Available", - f"{player_obj.name} is not a free agent." + "Player Not Available", f"{player_obj.name} is not a free agent." ) await interaction.followup.send(embed=embed) return # Validate cap space - roster = await team_service.get_team_roster(team.id, 'current') + roster = await team_service.get_team_roster(team.id, "current") if not roster: embed = await create_pick_illegal_embed( - "Roster Error", - f"Could not retrieve roster for {team.abbrev}." + "Roster Error", f"Could not retrieve roster for {team.abbrev}." ) await interaction.followup.send(embed=embed) return - is_valid, projected_total, cap_limit = await validate_cap_space(roster, player_obj.wara, team) + is_valid, projected_total, cap_limit = await validate_cap_space( + roster, player_obj.wara, team + ) if not is_valid: embed = await create_pick_illegal_embed( "Cap Space Exceeded", - f"Drafting {player_obj.name} would put you at {projected_total:.2f} sWAR (limit: {cap_limit:.2f})." + f"Drafting {player_obj.name} would put you at {projected_total:.2f} sWAR (limit: {cap_limit:.2f}).", ) await interaction.followup.send(embed=embed) return # Execute pick (using pick_to_use which may be current or skipped pick) updated_pick = await draft_pick_service.update_pick_selection( - pick_to_use.id, - player_obj.id + pick_to_use.id, player_obj.id ) if not updated_pick: embed = await create_pick_illegal_embed( - "Pick Failed", - "Failed to update draft pick. Please try again." + "Pick Failed", "Failed to update draft pick. Please try again." ) await interaction.followup.send(embed=embed) return # Get current league state for dem_week calculation from services.league_service import league_service + current = await league_service.get_current_state() # Update player team with dem_week set to current.week + 2 for draft picks updated_player = await player_service.update_player_team( - player_obj.id, - team.id, - dem_week=current.week + 2 if current else None + player_obj.id, team.id, dem_week=current.week + 2 if current else None ) if not updated_player: @@ -324,7 +308,7 @@ class DraftPicksCog(commands.Cog): pick=pick_to_use, player=player_obj, team=team, - guild=interaction.guild + guild=interaction.guild, ) # Determine if this was a skipped pick @@ -332,11 +316,7 @@ class DraftPicksCog(commands.Cog): # Send success message success_embed = await create_pick_success_embed( - player_obj, - team, - pick_to_use.overall, - projected_total, - cap_limit + player_obj, team, pick_to_use.overall, projected_total, cap_limit ) # Add note if this was a skipped pick @@ -348,7 +328,10 @@ class DraftPicksCog(commands.Cog): await interaction.followup.send(embed=success_embed) # Post draft card to ping channel (only if different from command channel) - if draft_data.ping_channel and draft_data.ping_channel != interaction.channel_id: + if ( + draft_data.ping_channel + and draft_data.ping_channel != interaction.channel_id + ): guild = interaction.guild if guild: ping_channel = guild.get_channel(draft_data.ping_channel) @@ -369,7 +352,9 @@ class DraftPicksCog(commands.Cog): if guild: result_channel = guild.get_channel(draft_data.result_channel) if result_channel: - result_card = await create_player_draft_card(player_obj, pick_to_use) + result_card = await create_player_draft_card( + player_obj, pick_to_use + ) # Add skipped pick context to result card if is_skipped_pick: @@ -379,7 +364,9 @@ class DraftPicksCog(commands.Cog): await result_channel.send(embed=result_card) else: - self.logger.warning(f"Could not find result channel {draft_data.result_channel}") + self.logger.warning( + f"Could not find result channel {draft_data.result_channel}" + ) # Only advance the draft if this was the current pick (not a skipped pick) if not is_skipped_pick: @@ -391,8 +378,7 @@ class DraftPicksCog(commands.Cog): ping_channel = guild.get_channel(draft_data.ping_channel) if ping_channel: await self._post_on_clock_announcement( - ping_channel=ping_channel, - guild=guild + ping_channel=ping_channel, guild=guild ) self.logger.info( @@ -402,12 +388,7 @@ class DraftPicksCog(commands.Cog): ) async def _write_pick_to_sheets( - self, - draft_data, - pick, - player, - team, - guild: Optional[discord.Guild] + self, draft_data, pick, player, team, guild: Optional[discord.Guild] ): """ Write pick to Google Sheets (fire-and-forget with ping channel notification on failure). @@ -426,10 +407,12 @@ class DraftPicksCog(commands.Cog): success = await draft_sheet_service.write_pick( season=config.sba_season, overall=pick.overall, - orig_owner_abbrev=pick.origowner.abbrev if pick.origowner else team.abbrev, + orig_owner_abbrev=( + pick.origowner.abbrev if pick.origowner else team.abbrev + ), owner_abbrev=team.abbrev, player_name=player.name, - swar=player.wara + swar=player.wara, ) if not success: @@ -439,7 +422,7 @@ class DraftPicksCog(commands.Cog): channel_id=draft_data.ping_channel, pick_overall=pick.overall, player_name=player.name, - reason="Sheet write returned failure" + reason="Sheet write returned failure", ) except Exception as e: @@ -450,7 +433,7 @@ class DraftPicksCog(commands.Cog): channel_id=draft_data.ping_channel, pick_overall=pick.overall, player_name=player.name, - reason=str(e) + reason=str(e), ) async def _notify_sheet_failure( @@ -459,7 +442,7 @@ class DraftPicksCog(commands.Cog): channel_id: Optional[int], pick_overall: int, player_name: str, - reason: str + reason: str, ): """ Post notification to ping channel when sheet write fails. @@ -476,7 +459,7 @@ class DraftPicksCog(commands.Cog): try: channel = guild.get_channel(channel_id) - if channel and hasattr(channel, 'send'): + if channel and hasattr(channel, "send"): await channel.send( f"⚠️ **Sheet Sync Failed** - Pick #{pick_overall} ({player_name}) " f"was not written to the draft sheet. " @@ -486,9 +469,7 @@ class DraftPicksCog(commands.Cog): self.logger.error(f"Failed to send sheet failure notification: {e}") async def _post_on_clock_announcement( - self, - ping_channel, - guild: discord.Guild + self, ping_channel, guild: discord.Guild ) -> None: """ Post the on-clock announcement embed for the next team with role ping. @@ -510,23 +491,26 @@ class DraftPicksCog(commands.Cog): # Get the new current pick next_pick = await draft_pick_service.get_pick( - config.sba_season, - updated_draft_data.currentpick + config.sba_season, updated_draft_data.currentpick ) if not next_pick or not next_pick.owner: - self.logger.error(f"Could not get pick #{updated_draft_data.currentpick} for announcement") + self.logger.error( + f"Could not get pick #{updated_draft_data.currentpick} for announcement" + ) return # Get recent picks (last 5 completed) recent_picks = await draft_pick_service.get_recent_picks( config.sba_season, updated_draft_data.currentpick - 1, # Start from previous pick - limit=5 + limit=5, ) # Get team roster for sWAR calculation - team_roster = await roster_service.get_team_roster(next_pick.owner.id, "current") + team_roster = await roster_service.get_team_roster( + next_pick.owner.id, "current" + ) roster_swar = team_roster.total_wara if team_roster else 0.0 cap_limit = get_team_salary_cap(next_pick.owner) @@ -534,7 +518,9 @@ class DraftPicksCog(commands.Cog): top_roster_players = [] if team_roster: all_players = team_roster.all_players - sorted_players = sorted(all_players, key=lambda p: p.wara if p.wara else 0.0, reverse=True) + sorted_players = sorted( + all_players, key=lambda p: p.wara if p.wara else 0.0, reverse=True + ) top_roster_players = sorted_players[:5] # Get sheet URL @@ -548,7 +534,7 @@ class DraftPicksCog(commands.Cog): roster_swar=roster_swar, cap_limit=cap_limit, top_roster_players=top_roster_players, - sheet_url=sheet_url + sheet_url=sheet_url, ) # Mention the team's role (using team.lname) @@ -557,10 +543,14 @@ class DraftPicksCog(commands.Cog): if team_role: team_mention = f"{team_role.mention} " else: - self.logger.warning(f"Could not find role for team {next_pick.owner.lname}") + self.logger.warning( + f"Could not find role for team {next_pick.owner.lname}" + ) await ping_channel.send(content=team_mention, embed=embed) - self.logger.info(f"Posted on-clock announcement for pick #{updated_draft_data.currentpick}") + self.logger.info( + f"Posted on-clock announcement for pick #{updated_draft_data.currentpick}" + ) except Exception as e: self.logger.error("Error posting on-clock announcement", error=e) diff --git a/config.py b/config.py index 3964a7d..4321b8f 100644 --- a/config.py +++ b/config.py @@ -87,7 +87,7 @@ class BotConfig(BaseSettings): # Application settings log_level: str = "INFO" environment: str = "development" - testing: bool = True + testing: bool = False # Google Sheets settings sheets_credentials_path: str = "/app/data/major-domo-service-creds.json" diff --git a/models/custom_command.py b/models/custom_command.py index a65b3b2..94e7f9c 100644 --- a/models/custom_command.py +++ b/models/custom_command.py @@ -3,7 +3,8 @@ Custom Command models for Discord Bot v2.0 Modern Pydantic models for the custom command system with full type safety. """ -from datetime import datetime + +from datetime import UTC, datetime from typing import Optional import re @@ -13,136 +14,158 @@ from models.base import SBABaseModel class CustomCommandCreator(SBABaseModel): """Creator of custom commands.""" - id: int = Field(..., description="Database ID") # type: ignore + + id: int = Field(..., description="Database ID") # type: ignore discord_id: int = Field(..., description="Discord user ID") username: str = Field(..., description="Discord username") display_name: Optional[str] = Field(None, description="Discord display name") - created_at: datetime = Field(..., description="When creator was first recorded") # type: ignore + created_at: datetime = Field(..., description="When creator was first recorded") # type: ignore total_commands: int = Field(0, description="Total commands created by this user") active_commands: int = Field(0, description="Currently active commands") class CustomCommand(SBABaseModel): """A custom command created by a user.""" - id: int = Field(..., description="Database ID") # type: ignore + + id: int = Field(..., description="Database ID") # type: ignore name: str = Field(..., description="Command name (unique)") content: str = Field(..., description="Command response content") - creator_id: Optional[int] = Field(None, description="ID of the creator (may be missing from execute endpoint)") + creator_id: Optional[int] = Field( + None, description="ID of the creator (may be missing from execute endpoint)" + ) creator: Optional[CustomCommandCreator] = Field(None, description="Creator details") - + # Timestamps - created_at: datetime = Field(..., description="When command was created") # type: ignore - updated_at: Optional[datetime] = Field(None, description="When command was last updated") # type: ignore - last_used: Optional[datetime] = Field(None, description="When command was last executed") - + created_at: datetime = Field(..., description="When command was created") # type: ignore + updated_at: Optional[datetime] = Field(None, description="When command was last updated") # type: ignore + last_used: Optional[datetime] = Field( + None, description="When command was last executed" + ) + # Usage tracking use_count: int = Field(0, description="Total times command has been used") warning_sent: bool = Field(False, description="Whether cleanup warning was sent") - + # Metadata is_active: bool = Field(True, description="Whether command is currently active") - tags: Optional[list[str]] = Field(None, description="Optional tags for categorization") - - @field_validator('name') + tags: Optional[list[str]] = Field( + None, description="Optional tags for categorization" + ) + + @field_validator("name") @classmethod def validate_name(cls, v): """Validate command name.""" if not v or len(v.strip()) == 0: raise ValueError("Command name cannot be empty") - + name = v.strip().lower() - + # Length validation if len(name) < 2: raise ValueError("Command name must be at least 2 characters") if len(name) > 32: raise ValueError("Command name cannot exceed 32 characters") - + # Character validation - only allow alphanumeric, dashes, underscores - if not re.match(r'^[a-z0-9_-]+$', name): - raise ValueError("Command name can only contain letters, numbers, dashes, and underscores") - + if not re.match(r"^[a-z0-9_-]+$", name): + raise ValueError( + "Command name can only contain letters, numbers, dashes, and underscores" + ) + # Reserved names reserved = { - 'help', 'ping', 'info', 'list', 'create', 'delete', 'edit', - 'admin', 'mod', 'owner', 'bot', 'system', 'config' + "help", + "ping", + "info", + "list", + "create", + "delete", + "edit", + "admin", + "mod", + "owner", + "bot", + "system", + "config", } if name in reserved: raise ValueError(f"'{name}' is a reserved command name") - + return name.lower() - - @field_validator('content') + + @field_validator("content") @classmethod def validate_content(cls, v): """Validate command content.""" if not v or len(v.strip()) == 0: raise ValueError("Command content cannot be empty") - + content = v.strip() - + # Length validation if len(content) > 2000: raise ValueError("Command content cannot exceed 2000 characters") - + # Basic content filtering - prohibited = ['@everyone', '@here'] + prohibited = ["@everyone", "@here"] content_lower = content.lower() for term in prohibited: if term in content_lower: raise ValueError(f"Command content cannot contain '{term}'") - + return content - + @property def days_since_last_use(self) -> Optional[int]: """Calculate days since last use.""" if not self.last_used: return None - return (datetime.now() - self.last_used).days - + return (datetime.now(UTC) - self.last_used).days + @property def is_eligible_for_warning(self) -> bool: """Check if command is eligible for deletion warning.""" if not self.last_used or self.warning_sent: return False - return self.days_since_last_use >= 60 # type: ignore - + return self.days_since_last_use >= 60 # type: ignore + @property def is_eligible_for_deletion(self) -> bool: """Check if command is eligible for deletion.""" if not self.last_used: return False - return self.days_since_last_use >= 90 # type: ignore - + return self.days_since_last_use >= 90 # type: ignore + @property def popularity_score(self) -> float: """Calculate popularity score based on usage and recency.""" if self.use_count == 0: return 0.0 - + # Base score from usage base_score = min(self.use_count / 10.0, 10.0) # Max 10 points from usage - + # Recency modifier if self.last_used: days_ago = self.days_since_last_use - if days_ago <= 7: # type: ignore + if days_ago <= 7: # type: ignore recency_modifier = 1.5 # Recent use bonus - elif days_ago <= 30: # type: ignore + elif days_ago <= 30: # type: ignore recency_modifier = 1.0 # No modifier - elif days_ago <= 60: # type: ignore + elif days_ago <= 60: # type: ignore recency_modifier = 0.7 # Slight penalty else: recency_modifier = 0.3 # Old command penalty else: recency_modifier = 0.1 # Never used - + return base_score * recency_modifier class CustomCommandSearchFilters(BaseModel): """Filters for searching custom commands.""" + name_contains: Optional[str] = None creator_id: Optional[int] = None creator_name: Optional[str] = None @@ -150,33 +173,43 @@ class CustomCommandSearchFilters(BaseModel): max_days_unused: Optional[int] = None has_tags: Optional[list[str]] = None is_active: bool = True - + # Sorting options - sort_by: str = Field('name', description="Sort field: name, created_at, last_used, use_count, popularity") + sort_by: str = Field( + "name", + description="Sort field: name, created_at, last_used, use_count, popularity", + ) sort_desc: bool = Field(False, description="Sort in descending order") - + # Pagination page: int = Field(1, description="Page number (1-based)") page_size: int = Field(25, description="Items per page") - - @field_validator('sort_by') + + @field_validator("sort_by") @classmethod def validate_sort_by(cls, v): """Validate sort field.""" - valid_sorts = {'name', 'created_at', 'last_used', 'use_count', 'popularity', 'creator'} + valid_sorts = { + "name", + "created_at", + "last_used", + "use_count", + "popularity", + "creator", + } if v not in valid_sorts: raise ValueError(f"sort_by must be one of: {', '.join(valid_sorts)}") return v - - @field_validator('page') + + @field_validator("page") @classmethod def validate_page(cls, v): """Validate page number.""" if v < 1: raise ValueError("Page number must be >= 1") return v - - @field_validator('page_size') + + @field_validator("page_size") @classmethod def validate_page_size(cls, v): """Validate page size.""" @@ -187,18 +220,19 @@ class CustomCommandSearchFilters(BaseModel): class CustomCommandSearchResult(BaseModel): """Result of a custom command search.""" + commands: list[CustomCommand] total_count: int page: int page_size: int total_pages: int has_more: bool - + @property def start_index(self) -> int: """Get the starting index for this page.""" return (self.page - 1) * self.page_size + 1 - + @property def end_index(self) -> int: """Get the ending index for this page.""" @@ -207,30 +241,31 @@ class CustomCommandSearchResult(BaseModel): class CustomCommandStats(BaseModel): """Statistics about custom commands.""" + total_commands: int active_commands: int total_creators: int total_uses: int - + # Usage statistics most_popular_command: Optional[CustomCommand] = None most_active_creator: Optional[CustomCommandCreator] = None recent_commands_count: int = 0 # Commands created in last 7 days - + # Cleanup statistics commands_needing_warning: int = 0 commands_eligible_for_deletion: int = 0 - + @property def average_uses_per_command(self) -> float: """Calculate average uses per command.""" if self.active_commands == 0: return 0.0 return self.total_uses / self.active_commands - + @property def average_commands_per_creator(self) -> float: """Calculate average commands per creator.""" if self.total_creators == 0: return 0.0 - return self.active_commands / self.total_creators \ No newline at end of file + return self.active_commands / self.total_creators diff --git a/models/draft_data.py b/models/draft_data.py index 058f79e..d411622 100644 --- a/models/draft_data.py +++ b/models/draft_data.py @@ -3,8 +3,9 @@ Draft configuration and state model Represents the current draft settings and timer state. """ + from typing import Optional -from datetime import datetime +from datetime import UTC, datetime from pydantic import Field, field_validator from models.base import SBABaseModel @@ -15,10 +16,18 @@ class DraftData(SBABaseModel): currentpick: int = Field(0, description="Current pick number in progress") timer: bool = Field(False, description="Whether draft timer is active") - paused: bool = Field(False, description="Whether draft is paused (blocks all picks)") - pick_deadline: Optional[datetime] = Field(None, description="Deadline for current pick") - result_channel: Optional[int] = Field(None, description="Discord channel ID for draft results") - ping_channel: Optional[int] = Field(None, description="Discord channel ID for draft pings") + paused: bool = Field( + False, description="Whether draft is paused (blocks all picks)" + ) + pick_deadline: Optional[datetime] = Field( + None, description="Deadline for current pick" + ) + result_channel: Optional[int] = Field( + None, description="Discord channel ID for draft results" + ) + ping_channel: Optional[int] = Field( + None, description="Discord channel ID for draft pings" + ) pick_minutes: int = Field(1, description="Minutes allowed per pick") @field_validator("result_channel", "ping_channel", mode="before") @@ -30,7 +39,7 @@ class DraftData(SBABaseModel): if isinstance(v, str): return int(v) return v - + @property def is_draft_active(self) -> bool: """Check if the draft is currently active (timer running and not paused).""" @@ -41,7 +50,7 @@ class DraftData(SBABaseModel): """Check if the current pick deadline has passed.""" if not self.pick_deadline: return False - return datetime.now() > self.pick_deadline + return datetime.now(UTC) > self.pick_deadline @property def can_make_picks(self) -> bool: @@ -55,4 +64,4 @@ class DraftData(SBABaseModel): status = "Active" else: status = "Inactive" - return f"Draft {status}: Pick {self.currentpick} ({self.pick_minutes}min timer)" \ No newline at end of file + return f"Draft {status}: Pick {self.currentpick} ({self.pick_minutes}min timer)" diff --git a/models/help_command.py b/models/help_command.py index 8f37b9a..2262061 100644 --- a/models/help_command.py +++ b/models/help_command.py @@ -5,7 +5,8 @@ Modern Pydantic models for the custom help system with full type safety. Allows admins and help editors to create custom help topics for league documentation, resources, FAQs, links, and guides. """ -from datetime import datetime + +from datetime import UTC, datetime from typing import Optional import re @@ -15,6 +16,7 @@ from models.base import SBABaseModel class HelpCommand(SBABaseModel): """A help topic created by an admin or help editor.""" + id: int = Field(..., description="Database ID") # type: ignore name: str = Field(..., description="Help topic name (unique)") title: str = Field(..., description="Display title") @@ -22,17 +24,23 @@ class HelpCommand(SBABaseModel): category: Optional[str] = Field(None, description="Category for organization") # Audit fields - created_by_discord_id: str = Field(..., description="Creator Discord ID (stored as text)") + created_by_discord_id: str = Field( + ..., description="Creator Discord ID (stored as text)" + ) created_at: datetime = Field(..., description="When help topic was created") # type: ignore updated_at: Optional[datetime] = Field(None, description="When help topic was last updated") # type: ignore - last_modified_by: Optional[str] = Field(None, description="Discord ID of last editor (stored as text)") + last_modified_by: Optional[str] = Field( + None, description="Discord ID of last editor (stored as text)" + ) # Status and metrics - is_active: bool = Field(True, description="Whether help topic is active (soft delete)") + is_active: bool = Field( + True, description="Whether help topic is active (soft delete)" + ) view_count: int = Field(0, description="Number of times viewed") display_order: int = Field(0, description="Sort order for display") - @field_validator('name') + @field_validator("name") @classmethod def validate_name(cls, v): """Validate help topic name.""" @@ -48,12 +56,14 @@ class HelpCommand(SBABaseModel): raise ValueError("Help topic name cannot exceed 32 characters") # Character validation - only allow alphanumeric, dashes, underscores - if not re.match(r'^[a-z0-9_-]+$', name): - raise ValueError("Help topic name can only contain letters, numbers, dashes, and underscores") + if not re.match(r"^[a-z0-9_-]+$", name): + raise ValueError( + "Help topic name can only contain letters, numbers, dashes, and underscores" + ) return name.lower() - @field_validator('title') + @field_validator("title") @classmethod def validate_title(cls, v): """Validate help topic title.""" @@ -68,7 +78,7 @@ class HelpCommand(SBABaseModel): return title - @field_validator('content') + @field_validator("content") @classmethod def validate_content(cls, v): """Validate help topic content.""" @@ -86,7 +96,7 @@ class HelpCommand(SBABaseModel): return content - @field_validator('category') + @field_validator("category") @classmethod def validate_category(cls, v): """Validate category if provided.""" @@ -103,8 +113,10 @@ class HelpCommand(SBABaseModel): raise ValueError("Category cannot exceed 50 characters") # Character validation - if not re.match(r'^[a-z0-9_-]+$', category): - raise ValueError("Category can only contain letters, numbers, dashes, and underscores") + if not re.match(r"^[a-z0-9_-]+$", category): + raise ValueError( + "Category can only contain letters, numbers, dashes, and underscores" + ) return category @@ -118,12 +130,12 @@ class HelpCommand(SBABaseModel): """Calculate days since last update.""" if not self.updated_at: return None - return (datetime.now() - self.updated_at).days + return (datetime.now(UTC) - self.updated_at).days @property def days_since_creation(self) -> int: """Calculate days since creation.""" - return (datetime.now() - self.created_at).days + return (datetime.now(UTC) - self.created_at).days @property def popularity_score(self) -> float: @@ -153,28 +165,40 @@ class HelpCommand(SBABaseModel): class HelpCommandSearchFilters(BaseModel): """Filters for searching help commands.""" + name_contains: Optional[str] = None category: Optional[str] = None is_active: bool = True # Sorting - sort_by: str = Field('name', description="Sort field: name, category, created_at, view_count, display_order") + sort_by: str = Field( + "name", + description="Sort field: name, category, created_at, view_count, display_order", + ) sort_desc: bool = Field(False, description="Sort in descending order") # Pagination page: int = Field(1, description="Page number (1-based)") page_size: int = Field(25, description="Items per page") - @field_validator('sort_by') + @field_validator("sort_by") @classmethod def validate_sort_by(cls, v): """Validate sort field.""" - valid_sorts = {'name', 'title', 'category', 'created_at', 'updated_at', 'view_count', 'display_order'} + valid_sorts = { + "name", + "title", + "category", + "created_at", + "updated_at", + "view_count", + "display_order", + } if v not in valid_sorts: raise ValueError(f"sort_by must be one of: {', '.join(valid_sorts)}") return v - @field_validator('page') + @field_validator("page") @classmethod def validate_page(cls, v): """Validate page number.""" @@ -182,7 +206,7 @@ class HelpCommandSearchFilters(BaseModel): raise ValueError("Page number must be >= 1") return v - @field_validator('page_size') + @field_validator("page_size") @classmethod def validate_page_size(cls, v): """Validate page size.""" @@ -193,6 +217,7 @@ class HelpCommandSearchFilters(BaseModel): class HelpCommandSearchResult(BaseModel): """Result of a help command search.""" + help_commands: list[HelpCommand] total_count: int page: int @@ -213,6 +238,7 @@ class HelpCommandSearchResult(BaseModel): class HelpCommandStats(BaseModel): """Statistics about help commands.""" + total_commands: int active_commands: int total_views: int diff --git a/services/custom_commands_service.py b/services/custom_commands_service.py index ff8cf11..72838bb 100644 --- a/services/custom_commands_service.py +++ b/services/custom_commands_service.py @@ -3,17 +3,18 @@ Custom Commands Service for Discord Bot v2.0 Modern async service layer for managing custom commands with full type safety. """ + import math -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Optional, List, Any, Tuple from utils.logging import get_contextual_logger from models.custom_command import ( - CustomCommand, - CustomCommandCreator, + CustomCommand, + CustomCommandCreator, CustomCommandSearchFilters, CustomCommandSearchResult, - CustomCommandStats + CustomCommandStats, ) from services.base_service import BaseService from exceptions import BotException @@ -21,41 +22,44 @@ from exceptions import BotException class CustomCommandNotFoundError(BotException): """Raised when a custom command is not found.""" + pass class CustomCommandExistsError(BotException): """Raised when trying to create a command that already exists.""" + pass class CustomCommandPermissionError(BotException): """Raised when user lacks permission for command operation.""" + pass class CustomCommandsService(BaseService[CustomCommand]): """Service for managing custom commands.""" - + def __init__(self): - super().__init__(CustomCommand, 'custom_commands') - self.logger = get_contextual_logger(f'{__name__}.CustomCommandsService') + super().__init__(CustomCommand, "custom_commands") + self.logger = get_contextual_logger(f"{__name__}.CustomCommandsService") self.logger.info("CustomCommandsService initialized") - + # === Command CRUD Operations === - + async def create_command( - self, - name: str, - content: str, + self, + name: str, + content: str, creator_discord_id: int, creator_username: str, creator_display_name: Optional[str] = None, - tags: Optional[List[str]] = None + tags: Optional[List[str]] = None, ) -> CustomCommand: """ Create a new custom command. - + Args: name: Command name (will be validated and normalized) content: Command response content @@ -63,10 +67,10 @@ class CustomCommandsService(BaseService[CustomCommand]): creator_username: Discord username creator_display_name: Discord display name (optional) tags: Optional tags for categorization - + Returns: The created CustomCommand - + Raises: CustomCommandExistsError: If command name already exists ValidationError: If name or content fails validation @@ -78,320 +82,323 @@ class CustomCommandsService(BaseService[CustomCommand]): except CustomCommandNotFoundError: # Command doesn't exist, which is what we want pass - + # Get or create creator creator = await self.get_or_create_creator( discord_id=creator_discord_id, username=creator_username, - display_name=creator_display_name + display_name=creator_display_name, ) - + # Create command data - now = datetime.now() + now = datetime.now(UTC) command_data = { - 'name': name.lower().strip(), - 'content': content.strip(), - 'creator_id': creator.id, - 'created_at': now.isoformat(), - 'last_used': now.isoformat(), # Set initial last_used to creation time - 'use_count': 0, - 'warning_sent': False, - 'is_active': True, - 'tags': tags or [] + "name": name.lower().strip(), + "content": content.strip(), + "creator_id": creator.id, + "created_at": now.isoformat(), + "last_used": now.isoformat(), # Set initial last_used to creation time + "use_count": 0, + "warning_sent": False, + "is_active": True, + "tags": tags or [], } - + # Create via API result = await self.create(command_data) if not result: raise BotException("Failed to create custom command") - + # Update creator stats await self._update_creator_stats(creator.id) - - self.logger.info("Custom command created", - command_name=name, - creator_id=creator_discord_id, - content_length=len(content)) - + + self.logger.info( + "Custom command created", + command_name=name, + creator_id=creator_discord_id, + content_length=len(content), + ) + # Return full command with creator info return await self.get_command_by_name(name) - - async def get_command_by_name( - self, - name: str - ) -> CustomCommand: + + async def get_command_by_name(self, name: str) -> CustomCommand: """ Get a custom command by name. - + Args: name: Command name to search for - + Returns: CustomCommand if found - + Raises: CustomCommandNotFoundError: If command not found """ normalized_name = name.lower().strip() - + try: # Use the dedicated by_name endpoint for exact lookup client = await self.get_client() - data = await client.get(f'custom_commands/by_name/{normalized_name}') - + data = await client.get(f"custom_commands/by_name/{normalized_name}") + if not data: raise CustomCommandNotFoundError(f"Custom command '{name}' not found") - + # Convert API data to CustomCommand return self.model_class.from_api_data(data) - + except Exception as e: if "404" in str(e) or "not found" in str(e).lower(): raise CustomCommandNotFoundError(f"Custom command '{name}' not found") else: - self.logger.error("Failed to get command by name", - command_name=name, - error=e) + self.logger.error( + "Failed to get command by name", command_name=name, error=e + ) raise BotException(f"Failed to retrieve command '{name}': {e}") - + async def update_command( - self, - name: str, + self, + name: str, new_content: str, updater_discord_id: int, - new_tags: Optional[List[str]] = None + new_tags: Optional[List[str]] = None, ) -> CustomCommand: """ Update an existing custom command. - + Args: name: Command name to update new_content: New command content updater_discord_id: Discord ID of user making the update new_tags: New tags (optional) - + Returns: Updated CustomCommand - + Raises: CustomCommandNotFoundError: If command doesn't exist CustomCommandPermissionError: If user doesn't own the command """ command = await self.get_command_by_name(name) - + # Check permissions if command.creator.discord_id != updater_discord_id: raise CustomCommandPermissionError("You can only edit commands you created") - + # Prepare update data - include all required fields to avoid NULL constraints update_data = { - 'name': command.name, - 'content': new_content.strip(), - 'creator_id': command.creator_id, - 'created_at': command.created_at.isoformat(), # Preserve original creation time - 'updated_at': datetime.now().isoformat(), - 'last_used': command.last_used.isoformat() if command.last_used else None, - 'warning_sent': False, # Reset warning if command is updated - 'is_active': command.is_active, # Preserve active status - 'use_count': command.use_count # Preserve usage count + "name": command.name, + "content": new_content.strip(), + "creator_id": command.creator_id, + "created_at": command.created_at.isoformat(), # Preserve original creation time + "updated_at": datetime.now(UTC).isoformat(), + "last_used": command.last_used.isoformat() if command.last_used else None, + "warning_sent": False, # Reset warning if command is updated + "is_active": command.is_active, # Preserve active status + "use_count": command.use_count, # Preserve usage count } - + if new_tags is not None: - update_data['tags'] = new_tags + update_data["tags"] = new_tags else: # Preserve existing tags if not being updated - update_data['tags'] = command.tags - + update_data["tags"] = command.tags + # Update via API - result = await self.update_item_by_field('name', name, update_data) + result = await self.update_item_by_field("name", name, update_data) if not result: raise BotException("Failed to update custom command") - - self.logger.info("Custom command updated", - command_name=name, - updater_id=updater_discord_id, - new_content_length=len(new_content)) - + + self.logger.info( + "Custom command updated", + command_name=name, + updater_id=updater_discord_id, + new_content_length=len(new_content), + ) + return await self.get_command_by_name(name) - + async def delete_command( - self, - name: str, - deleter_discord_id: int, - force: bool = False + self, name: str, deleter_discord_id: int, force: bool = False ) -> bool: """ Delete a custom command. - + Args: name: Command name to delete deleter_discord_id: Discord ID of user deleting the command force: Whether to force delete (admin override) - + Returns: True if successfully deleted - + Raises: CustomCommandNotFoundError: If command doesn't exist CustomCommandPermissionError: If user doesn't own the command and force=False """ command = await self.get_command_by_name(name) - + # Check permissions (unless force delete) if not force and command.creator.discord_id != deleter_discord_id: - raise CustomCommandPermissionError("You can only delete commands you created") - + raise CustomCommandPermissionError( + "You can only delete commands you created" + ) + # Delete via API - result = await self.delete_item_by_field('name', name) + result = await self.delete_item_by_field("name", name) if not result: raise BotException("Failed to delete custom command") - + # Update creator stats await self._update_creator_stats(command.creator_id) - - self.logger.info("Custom command deleted", - command_name=name, - deleter_id=deleter_discord_id, - was_forced=force) - + + self.logger.info( + "Custom command deleted", + command_name=name, + deleter_id=deleter_discord_id, + was_forced=force, + ) + return True - + async def execute_command(self, name: str) -> Tuple[CustomCommand, str]: """ Execute a custom command and update usage statistics. - + Args: name: Command name to execute - + Returns: Tuple of (CustomCommand, response_content) - + Raises: CustomCommandNotFoundError: If command doesn't exist """ normalized_name = name.lower().strip() - + try: # Use the dedicated execute endpoint which updates stats and returns the command client = await self.get_client() - data = await client.patch(f'custom_commands/by_name/{normalized_name}/execute') - + data = await client.patch( + f"custom_commands/by_name/{normalized_name}/execute" + ) + if not data: raise CustomCommandNotFoundError(f"Custom command '{name}' not found") - + # Convert API data to CustomCommand updated_command = self.model_class.from_api_data(data) - - self.logger.debug("Custom command executed", - command_name=name, - new_use_count=updated_command.use_count) - + + self.logger.debug( + "Custom command executed", + command_name=name, + new_use_count=updated_command.use_count, + ) + return updated_command, updated_command.content - + except Exception as e: if "404" in str(e) or "not found" in str(e).lower(): raise CustomCommandNotFoundError(f"Custom command '{name}' not found") else: - self.logger.error("Failed to execute command", - command_name=name, - error=e) + self.logger.error( + "Failed to execute command", command_name=name, error=e + ) raise BotException(f"Failed to execute command '{name}': {e}") - + # === Search and Listing === - + async def search_commands( - self, - filters: CustomCommandSearchFilters + self, filters: CustomCommandSearchFilters ) -> CustomCommandSearchResult: """ Search for custom commands with filtering and pagination. - + Args: filters: Search filters and pagination options - + Returns: CustomCommandSearchResult with matching commands """ # Build search parameters params = [] - + # Apply filters if filters.name_contains: - params.append(('name__icontains', filters.name_contains)) - + params.append(("name__icontains", filters.name_contains)) + if filters.creator_id: - params.append(('creator_id', filters.creator_id)) - + params.append(("creator_id", filters.creator_id)) + if filters.min_uses: - params.append(('use_count__gte', filters.min_uses)) - + params.append(("use_count__gte", filters.min_uses)) + if filters.max_days_unused: - cutoff_date = datetime.now() - timedelta(days=filters.max_days_unused) - params.append(('last_used__gte', cutoff_date.isoformat())) - - params.append(('is_active', filters.is_active)) - + cutoff_date = datetime.now(UTC) - timedelta(days=filters.max_days_unused) + params.append(("last_used__gte", cutoff_date.isoformat())) + + params.append(("is_active", filters.is_active)) + # Add sorting sort_field = filters.sort_by if filters.sort_desc: - sort_field = f'-{sort_field}' - params.append(('sort', sort_field)) - + sort_field = f"-{sort_field}" + params.append(("sort", sort_field)) + # Get total count for pagination total_count = await self._get_search_count(params) total_pages = math.ceil(total_count / filters.page_size) - + # Add pagination offset = (filters.page - 1) * filters.page_size - params.extend([ - ('limit', filters.page_size), - ('offset', offset) - ]) - + params.extend([("limit", filters.page_size), ("offset", offset)]) + # Execute search commands_data = await self.get_items_with_params(params) - + # Convert to CustomCommand objects (creator info is now included in API response) commands = [] for cmd_data in commands_data: # The API now returns complete creator data, so we can use it directly commands.append(cmd_data) - - self.logger.debug("Custom commands search completed", - total_results=total_count, - page=filters.page, - filters_applied=len([p for p in params if not p[0] in ['sort', 'limit', 'offset']])) - + + self.logger.debug( + "Custom commands search completed", + total_results=total_count, + page=filters.page, + filters_applied=len( + [p for p in params if not p[0] in ["sort", "limit", "offset"]] + ), + ) + return CustomCommandSearchResult( commands=commands, total_count=total_count, page=filters.page, page_size=filters.page_size, total_pages=total_pages, - has_more=filters.page < total_pages + has_more=filters.page < total_pages, ) - + async def get_commands_by_creator( - self, - creator_discord_id: int, - page: int = 1, - page_size: int = 25 + self, creator_discord_id: int, page: int = 1, page_size: int = 25 ) -> CustomCommandSearchResult: """Get all commands created by a specific user.""" try: # Use the main custom_commands endpoint with creator_discord_id filter client = await self.get_client() - + params = [ - ('creator_discord_id', creator_discord_id), - ('is_active', True), - ('sort', 'name'), - ('page', page), - ('page_size', page_size) + ("creator_discord_id", creator_discord_id), + ("is_active", True), + ("sort", "name"), + ("page", page), + ("page_size", page_size), ] - - data = await client.get('custom_commands', params=params) - + + data = await client.get("custom_commands", params=params) + if not data: return CustomCommandSearchResult( commands=[], @@ -399,44 +406,50 @@ class CustomCommandsService(BaseService[CustomCommand]): page=page, page_size=page_size, total_pages=0, - has_more=False + has_more=False, ) - + # Extract response data - custom_commands = data.get('custom_commands', []) - total_count = data.get('total_count', 0) - total_pages = data.get('total_pages', 0) - has_more = data.get('has_more', False) - + custom_commands = data.get("custom_commands", []) + total_count = data.get("total_count", 0) + total_pages = data.get("total_pages", 0) + has_more = data.get("has_more", False) + # Convert to CustomCommand objects (creator data is included in API response) commands = [] for cmd_data in custom_commands: try: commands.append(self.model_class.from_api_data(cmd_data)) except Exception as e: - self.logger.warning("Failed to create CustomCommand from API data", - command_id=cmd_data.get('id'), - error=e) + self.logger.warning( + "Failed to create CustomCommand from API data", + command_id=cmd_data.get("id"), + error=e, + ) continue - - self.logger.debug("Got commands by creator", - creator_discord_id=creator_discord_id, - returned_commands=len(commands), - total_count=total_count) - + + self.logger.debug( + "Got commands by creator", + creator_discord_id=creator_discord_id, + returned_commands=len(commands), + total_count=total_count, + ) + return CustomCommandSearchResult( commands=commands, total_count=total_count, page=page, page_size=page_size, total_pages=total_pages, - has_more=has_more + has_more=has_more, ) - + except Exception as e: - self.logger.error("Failed to get commands by creator", - creator_discord_id=creator_discord_id, - error=e) + self.logger.error( + "Failed to get commands by creator", + creator_discord_id=creator_discord_id, + error=e, + ) # Return empty result on error return CustomCommandSearchResult( commands=[], @@ -444,16 +457,12 @@ class CustomCommandsService(BaseService[CustomCommand]): page=page, page_size=page_size, total_pages=0, - has_more=False + has_more=False, ) - + async def get_popular_commands(self, limit: int = 10) -> List[CustomCommand]: """Get the most popular commands by usage.""" - params = [ - ('is_active', True), - ('sort', '-use_count'), - ('limit', limit) - ] + params = [("is_active", True), ("sort", "-use_count"), ("limit", limit)] commands_data = await self.get_items_with_params(params) @@ -464,62 +473,62 @@ class CustomCommandsService(BaseService[CustomCommand]): commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator)) except BotException as e: # Handle missing creator gracefully - self.logger.warning("Skipping popular command with missing creator", - command_id=cmd_data.id, - command_name=cmd_data.name, - creator_id=cmd_data.creator_id, - error=str(e)) + self.logger.warning( + "Skipping popular command with missing creator", + command_id=cmd_data.id, + command_name=cmd_data.name, + creator_id=cmd_data.creator_id, + error=str(e), + ) continue return commands - + async def get_command_names_for_autocomplete( - self, - partial_name: str = "", - limit: int = 25 + self, partial_name: str = "", limit: int = 25 ) -> List[str]: """ Get command names for Discord autocomplete. - + Args: partial_name: Partial command name to match limit: Maximum number of suggestions - + Returns: List of command names matching the partial input """ try: # Use the dedicated autocomplete endpoint for better performance client = await self.get_client() - params = [('limit', limit)] - + params = [("limit", limit)] + if partial_name: - params.append(('partial_name', partial_name.lower())) - - result = await client.get('custom_commands/autocomplete', params=params) - + params.append(("partial_name", partial_name.lower())) + + result = await client.get("custom_commands/autocomplete", params=params) + # The autocomplete endpoint returns a list of strings directly if isinstance(result, list): return result else: - self.logger.warning("Unexpected autocomplete response format", - response=result) + self.logger.warning( + "Unexpected autocomplete response format", response=result + ) return [] - + except Exception as e: - self.logger.error("Failed to get command names for autocomplete", - partial_name=partial_name, - error=e) + self.logger.error( + "Failed to get command names for autocomplete", + partial_name=partial_name, + error=e, + ) # Return empty list on error to not break Discord autocomplete return [] - + # === Creator Management === - + async def get_or_create_creator( - self, - discord_id: int, - username: str, - display_name: Optional[str] = None + self, discord_id: int, username: str, display_name: Optional[str] = None ) -> CustomCommandCreator: """Get existing creator or create a new one.""" try: @@ -532,98 +541,102 @@ class CustomCommandsService(BaseService[CustomCommand]): except BotException: # Creator doesn't exist, create new one pass - + # Create new creator creator_data = { - 'discord_id': discord_id, - 'username': username, - 'display_name': display_name, - 'created_at': datetime.now().isoformat(), - 'total_commands': 0, - 'active_commands': 0 + "discord_id": discord_id, + "username": username, + "display_name": display_name, + "created_at": datetime.now(UTC).isoformat(), + "total_commands": 0, + "active_commands": 0, } - - result = await self.create_item_in_table('custom_commands/creators', creator_data) + + result = await self.create_item_in_table( + "custom_commands/creators", creator_data + ) if not result: raise BotException("Failed to create command creator") - + return await self.get_creator_by_discord_id(discord_id) - + async def get_creator_by_discord_id(self, discord_id: int) -> CustomCommandCreator: """Get creator by Discord ID. - + Raises: BotException: If creator not found """ try: client = await self.get_client() - data = await client.get('custom_commands/creators', params=[('discord_id', discord_id)]) - - if not data or not data.get('creators'): + data = await client.get( + "custom_commands/creators", params=[("discord_id", discord_id)] + ) + + if not data or not data.get("creators"): raise BotException(f"Creator with Discord ID {discord_id} not found") - - creators = data['creators'] + + creators = data["creators"] if not creators: raise BotException(f"Creator with Discord ID {discord_id} not found") - + return CustomCommandCreator(**creators[0]) - + except Exception as e: if "not found" in str(e).lower(): raise BotException(f"Creator with Discord ID {discord_id} not found") else: - self.logger.error("Failed to get creator by Discord ID", - discord_id=discord_id, - error=e) + self.logger.error( + "Failed to get creator by Discord ID", + discord_id=discord_id, + error=e, + ) raise BotException(f"Failed to retrieve creator: {e}") - + async def get_creator_by_id(self, creator_id: int) -> CustomCommandCreator: """Get creator by database ID. - + Raises: BotException: If creator not found """ creators = await self.get_items_from_table_with_params( - 'custom_commands/creators', - [('id', creator_id)] + "custom_commands/creators", [("id", creator_id)] ) - + if not creators: raise BotException(f"Creator with ID {creator_id} not found") - + return CustomCommandCreator(**creators[0]) - + # === Statistics and Analytics === - + async def get_statistics(self) -> CustomCommandStats: """Get comprehensive statistics about custom commands.""" # Get basic counts total_commands = await self._get_search_count([]) - active_commands = await self._get_search_count([('is_active', True)]) + active_commands = await self._get_search_count([("is_active", True)]) total_creators = await self._get_creator_count() - + # Get total uses - all_commands = await self.get_items_with_params([('is_active', True)]) + all_commands = await self.get_items_with_params([("is_active", True)]) total_uses = sum(cmd.use_count for cmd in all_commands) - + # Get most popular command popular_commands = await self.get_popular_commands(limit=1) most_popular = popular_commands[0] if popular_commands else None - + # Get most active creator most_active_creator = await self._get_most_active_creator() - + # Get recent commands count - week_ago = datetime.now() - timedelta(days=7) - recent_count = await self._get_search_count([ - ('created_at__gte', week_ago.isoformat()), - ('is_active', True) - ]) - + week_ago = datetime.now(UTC) - timedelta(days=7) + recent_count = await self._get_search_count( + [("created_at__gte", week_ago.isoformat()), ("is_active", True)] + ) + # Get cleanup statistics warning_count = await self._get_commands_needing_warning_count() deletion_count = await self._get_commands_eligible_for_deletion_count() - + return CustomCommandStats( total_commands=total_commands, active_commands=active_commands, @@ -633,19 +646,19 @@ class CustomCommandsService(BaseService[CustomCommand]): most_active_creator=most_active_creator, recent_commands_count=recent_count, commands_needing_warning=warning_count, - commands_eligible_for_deletion=deletion_count + commands_eligible_for_deletion=deletion_count, ) - + # === Cleanup Operations === - + async def get_commands_needing_warning(self) -> List[CustomCommand]: """Get commands that need deletion warning (60+ days unused).""" - cutoff_date = datetime.now() - timedelta(days=60) + cutoff_date = datetime.now(UTC) - timedelta(days=60) params = [ - ('last_used__lt', cutoff_date.isoformat()), - ('warning_sent', False), - ('is_active', True) + ("last_used__lt", cutoff_date.isoformat()), + ("warning_sent", False), + ("is_active", True), ] commands_data = await self.get_items_with_params(params) @@ -657,23 +670,22 @@ class CustomCommandsService(BaseService[CustomCommand]): commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator)) except BotException as e: # Handle missing creator gracefully - self.logger.warning("Skipping command with missing creator", - command_id=cmd_data.id, - command_name=cmd_data.name, - creator_id=cmd_data.creator_id, - error=str(e)) + self.logger.warning( + "Skipping command with missing creator", + command_id=cmd_data.id, + command_name=cmd_data.name, + creator_id=cmd_data.creator_id, + error=str(e), + ) continue return commands - + async def get_commands_eligible_for_deletion(self) -> List[CustomCommand]: """Get commands eligible for deletion (90+ days unused).""" - cutoff_date = datetime.now() - timedelta(days=90) + cutoff_date = datetime.now(UTC) - timedelta(days=90) - params = [ - ('last_used__lt', cutoff_date.isoformat()), - ('is_active', True) - ] + params = [("last_used__lt", cutoff_date.isoformat()), ("is_active", True)] commands_data = await self.get_items_with_params(params) @@ -684,112 +696,117 @@ class CustomCommandsService(BaseService[CustomCommand]): commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator)) except BotException as e: # Handle missing creator gracefully - self.logger.warning("Skipping command with missing creator", - command_id=cmd_data.id, - command_name=cmd_data.name, - creator_id=cmd_data.creator_id, - error=str(e)) + self.logger.warning( + "Skipping command with missing creator", + command_id=cmd_data.id, + command_name=cmd_data.name, + creator_id=cmd_data.creator_id, + error=str(e), + ) continue return commands - + async def mark_warning_sent(self, command_name: str) -> bool: """Mark that a deletion warning has been sent for a command.""" result = await self.update_item_by_field( - 'name', - command_name, - {'warning_sent': True} + "name", command_name, {"warning_sent": True} ) return bool(result) - + async def bulk_delete_commands(self, command_names: List[str]) -> int: """Delete multiple commands and return count of successfully deleted.""" deleted_count = 0 - + for name in command_names: try: - await self.delete_item_by_field('name', name) + await self.delete_item_by_field("name", name) deleted_count += 1 except Exception as e: - self.logger.error("Failed to delete command during bulk delete", - command_name=name, - error=e) - + self.logger.error( + "Failed to delete command during bulk delete", + command_name=name, + error=e, + ) + return deleted_count - + # === Private Helper Methods === - + async def _update_creator_stats(self, creator_id: int) -> None: """Update creator statistics.""" # Count total and active commands - total = await self._get_search_count([('creator_id', creator_id)]) - active = await self._get_search_count([('creator_id', creator_id), ('is_active', True)]) - + total = await self._get_search_count([("creator_id", creator_id)]) + active = await self._get_search_count( + [("creator_id", creator_id), ("is_active", True)] + ) + # Update creator via API try: client = await self.get_client() - await client.put('custom_commands/creators', { - 'total_commands': total, - 'active_commands': active - }, object_id=creator_id) + await client.put( + "custom_commands/creators", + {"total_commands": total, "active_commands": active}, + object_id=creator_id, + ) except Exception as e: self.logger.error(f"Failed to update creator {creator_id} stats: {e}") - + async def _update_creator_info( - self, - creator_id: int, - username: str, - display_name: Optional[str] + self, creator_id: int, username: str, display_name: Optional[str] ) -> None: """Update creator username and display name.""" try: client = await self.get_client() - await client.put('custom_commands/creators', { - 'username': username, - 'display_name': display_name - }, object_id=creator_id) + await client.put( + "custom_commands/creators", + {"username": username, "display_name": display_name}, + object_id=creator_id, + ) except Exception as e: self.logger.error(f"Failed to update creator {creator_id} info: {e}") - + async def _get_search_count(self, params: List[Tuple[str, Any]]) -> int: """Get count of commands matching search parameters.""" # Use the count method from BaseService return await self.count(params) - + async def _get_creator_count(self) -> int: """Get total number of creators.""" - creators = await self.get_items_from_table_with_params('custom_commands/creators', []) + creators = await self.get_items_from_table_with_params( + "custom_commands/creators", [] + ) return len(creators) - + async def _get_most_active_creator(self) -> Optional[CustomCommandCreator]: """Get creator with most active commands.""" creators = await self.get_items_from_table_with_params( - 'custom_commands/creators', - [('sort', '-active_commands'), ('limit', 1)] + "custom_commands/creators", [("sort", "-active_commands"), ("limit", 1)] ) - + if not creators: return None - + return CustomCommandCreator(**creators[0]) - + async def _get_commands_needing_warning_count(self) -> int: """Get count of commands needing warning.""" - cutoff_date = datetime.now() - timedelta(days=60) - return await self._get_search_count([ - ('last_used__lt', cutoff_date.isoformat()), - ('warning_sent', False), - ('is_active', True) - ]) - + cutoff_date = datetime.now(UTC) - timedelta(days=60) + return await self._get_search_count( + [ + ("last_used__lt", cutoff_date.isoformat()), + ("warning_sent", False), + ("is_active", True), + ] + ) + async def _get_commands_eligible_for_deletion_count(self) -> int: """Get count of commands eligible for deletion.""" - cutoff_date = datetime.now() - timedelta(days=90) - return await self._get_search_count([ - ('last_used__lt', cutoff_date.isoformat()), - ('is_active', True) - ]) + cutoff_date = datetime.now(UTC) - timedelta(days=90) + return await self._get_search_count( + [("last_used__lt", cutoff_date.isoformat()), ("is_active", True)] + ) # Global service instance -custom_commands_service = CustomCommandsService() \ No newline at end of file +custom_commands_service = CustomCommandsService() diff --git a/services/draft_service.py b/services/draft_service.py index e8450cd..649b70d 100644 --- a/services/draft_service.py +++ b/services/draft_service.py @@ -3,14 +3,15 @@ Draft service for Discord Bot v2.0 Core draft business logic and state management. NO CACHING - draft state changes constantly. """ + import logging from typing import Optional, Dict, Any -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from services.base_service import BaseService from models.draft_data import DraftData -logger = logging.getLogger(f'{__name__}.DraftService') +logger = logging.getLogger(f"{__name__}.DraftService") class DraftService(BaseService[DraftData]): @@ -29,7 +30,7 @@ class DraftService(BaseService[DraftData]): def __init__(self): """Initialize draft service.""" - super().__init__(DraftData, 'draftdata') + super().__init__(DraftData, "draftdata") logger.debug("DraftService initialized") async def get_draft_data(self) -> Optional[DraftData]: @@ -62,9 +63,7 @@ class DraftService(BaseService[DraftData]): return None async def update_draft_data( - self, - draft_id: int, - updates: Dict[str, Any] + self, draft_id: int, updates: Dict[str, Any] ) -> Optional[DraftData]: """ Update draft configuration. @@ -92,10 +91,7 @@ class DraftService(BaseService[DraftData]): return None async def set_timer( - self, - draft_id: int, - active: bool, - pick_minutes: Optional[int] = None + self, draft_id: int, active: bool, pick_minutes: Optional[int] = None ) -> Optional[DraftData]: """ Enable or disable draft timer. @@ -109,27 +105,31 @@ class DraftService(BaseService[DraftData]): Updated DraftData instance """ try: - updates = {'timer': active} + updates = {"timer": active} if pick_minutes is not None: - updates['pick_minutes'] = pick_minutes + updates["pick_minutes"] = pick_minutes # Set deadline based on timer state if active: # Calculate new deadline if pick_minutes: - deadline = datetime.now() + timedelta(minutes=pick_minutes) + deadline = datetime.now(UTC) + timedelta(minutes=pick_minutes) else: # Get current pick_minutes from existing data current_data = await self.get_draft_data() if current_data: - deadline = datetime.now() + timedelta(minutes=current_data.pick_minutes) + deadline = datetime.now(UTC) + timedelta( + minutes=current_data.pick_minutes + ) else: - deadline = datetime.now() + timedelta(minutes=2) # Default fallback - updates['pick_deadline'] = deadline + deadline = datetime.now(UTC) + timedelta( + minutes=2 + ) # Default fallback + updates["pick_deadline"] = deadline else: # Set deadline far in future when timer inactive - updates['pick_deadline'] = datetime.now() + timedelta(days=690) + updates["pick_deadline"] = datetime.now(UTC) + timedelta(days=690) updated = await self.update_draft_data(draft_id, updates) @@ -146,9 +146,7 @@ class DraftService(BaseService[DraftData]): return None async def advance_pick( - self, - draft_id: int, - current_pick: int + self, draft_id: int, current_pick: int ) -> Optional[DraftData]: """ Advance to next pick in draft. @@ -199,12 +197,14 @@ class DraftService(BaseService[DraftData]): return await self.get_draft_data() # Update to next pick - updates = {'currentpick': next_pick} + updates = {"currentpick": next_pick} # Reset deadline if timer is active current_data = await self.get_draft_data() if current_data and current_data.timer: - updates['pick_deadline'] = datetime.now() + timedelta(minutes=current_data.pick_minutes) + updates["pick_deadline"] = datetime.now(UTC) + timedelta( + minutes=current_data.pick_minutes + ) updated = await self.update_draft_data(draft_id, updates) @@ -220,10 +220,7 @@ class DraftService(BaseService[DraftData]): return None async def set_current_pick( - self, - draft_id: int, - overall: int, - reset_timer: bool = True + self, draft_id: int, overall: int, reset_timer: bool = True ) -> Optional[DraftData]: """ Manually set current pick (admin operation). @@ -237,12 +234,14 @@ class DraftService(BaseService[DraftData]): Updated DraftData """ try: - updates = {'currentpick': overall} + updates = {"currentpick": overall} if reset_timer: current_data = await self.get_draft_data() if current_data and current_data.timer: - updates['pick_deadline'] = datetime.now() + timedelta(minutes=current_data.pick_minutes) + updates["pick_deadline"] = datetime.now(UTC) + timedelta( + minutes=current_data.pick_minutes + ) updated = await self.update_draft_data(draft_id, updates) @@ -261,7 +260,7 @@ class DraftService(BaseService[DraftData]): self, draft_id: int, ping_channel_id: Optional[int] = None, - result_channel_id: Optional[int] = None + result_channel_id: Optional[int] = None, ) -> Optional[DraftData]: """ Update draft Discord channel configuration. @@ -277,9 +276,9 @@ class DraftService(BaseService[DraftData]): try: updates = {} if ping_channel_id is not None: - updates['ping_channel'] = ping_channel_id + updates["ping_channel"] = ping_channel_id if result_channel_id is not None: - updates['result_channel'] = result_channel_id + updates["result_channel"] = result_channel_id if not updates: logger.warning("No channel updates provided") @@ -299,9 +298,7 @@ class DraftService(BaseService[DraftData]): return None async def reset_draft_deadline( - self, - draft_id: int, - minutes: Optional[int] = None + self, draft_id: int, minutes: Optional[int] = None ) -> Optional[DraftData]: """ Reset the current pick deadline. @@ -321,8 +318,8 @@ class DraftService(BaseService[DraftData]): return None minutes = current_data.pick_minutes - new_deadline = datetime.now() + timedelta(minutes=minutes) - updates = {'pick_deadline': new_deadline} + new_deadline = datetime.now(UTC) + timedelta(minutes=minutes) + updates = {"pick_deadline": new_deadline} updated = await self.update_draft_data(draft_id, updates) @@ -357,9 +354,9 @@ class DraftService(BaseService[DraftData]): # Pause the draft AND stop the timer # Set deadline far in future so it doesn't expire while paused updates = { - 'paused': True, - 'timer': False, - 'pick_deadline': datetime.now() + timedelta(days=690) + "paused": True, + "timer": False, + "pick_deadline": datetime.now(UTC) + timedelta(days=690), } updated = await self.update_draft_data(draft_id, updates) @@ -394,16 +391,14 @@ class DraftService(BaseService[DraftData]): pick_minutes = current_data.pick_minutes if current_data else 2 # Resume the draft AND restart the timer with fresh deadline - new_deadline = datetime.now() + timedelta(minutes=pick_minutes) - updates = { - 'paused': False, - 'timer': True, - 'pick_deadline': new_deadline - } + new_deadline = datetime.now(UTC) + timedelta(minutes=pick_minutes) + updates = {"paused": False, "timer": True, "pick_deadline": new_deadline} updated = await self.update_draft_data(draft_id, updates) if updated: - logger.info(f"Draft resumed - timer restarted with {pick_minutes}min deadline") + logger.info( + f"Draft resumed - timer restarted with {pick_minutes}min deadline" + ) else: logger.error("Failed to resume draft") diff --git a/services/draft_sheet_service.py b/services/draft_sheet_service.py index fa28f66..6c2851f 100644 --- a/services/draft_sheet_service.py +++ b/services/draft_sheet_service.py @@ -4,6 +4,7 @@ Draft Sheet Service Handles writing draft picks to Google Sheets for public tracking. Extends SheetsService to reuse authentication and async patterns. """ + import asyncio from typing import List, Optional, Tuple @@ -24,7 +25,7 @@ class DraftSheetService(SheetsService): If None, will use path from config """ super().__init__(credentials_path) - self.logger = get_contextual_logger(f'{__name__}.DraftSheetService') + self.logger = get_contextual_logger(f"{__name__}.DraftSheetService") self._config = get_config() async def write_pick( @@ -34,7 +35,7 @@ class DraftSheetService(SheetsService): orig_owner_abbrev: str, owner_abbrev: str, player_name: str, - swar: float + swar: float, ) -> bool: """ Write a single draft pick to the season's draft sheet. @@ -68,23 +69,19 @@ class DraftSheetService(SheetsService): return False try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # Get pygsheets client sheets = await loop.run_in_executor(None, self._get_client) # Open the draft sheet by key spreadsheet = await loop.run_in_executor( - None, - sheets.open_by_key, - sheet_key + None, sheets.open_by_key, sheet_key ) # Get the worksheet worksheet = await loop.run_in_executor( - None, - spreadsheet.worksheet_by_title, - self._config.draft_sheet_worksheet + None, spreadsheet.worksheet_by_title, self._config.draft_sheet_worksheet ) # Prepare pick data (4 columns: orig_owner, owner, player, swar) @@ -93,12 +90,12 @@ class DraftSheetService(SheetsService): # Calculate row (overall + 1 to leave row 1 for headers) row = overall + 1 start_column = self._config.draft_sheet_start_column - cell_range = f'{start_column}{row}' + cell_range = f"{start_column}{row}" # Write the pick data await loop.run_in_executor( None, - lambda: worksheet.update_values(crange=cell_range, values=pick_data) + lambda: worksheet.update_values(crange=cell_range, values=pick_data), ) self.logger.info( @@ -106,7 +103,7 @@ class DraftSheetService(SheetsService): season=season, overall=overall, player=player_name, - owner=owner_abbrev + owner=owner_abbrev, ) return True @@ -115,14 +112,12 @@ class DraftSheetService(SheetsService): f"Failed to write pick to draft sheet: {e}", season=season, overall=overall, - player=player_name + player=player_name, ) return False async def write_picks_batch( - self, - season: int, - picks: List[Tuple[int, str, str, str, float]] + self, season: int, picks: List[Tuple[int, str, str, str, float]] ) -> Tuple[int, int]: """ Write multiple draft picks to the sheet in a single batch operation. @@ -151,23 +146,19 @@ class DraftSheetService(SheetsService): return (0, 0) try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # Get pygsheets client sheets = await loop.run_in_executor(None, self._get_client) # Open the draft sheet by key spreadsheet = await loop.run_in_executor( - None, - sheets.open_by_key, - sheet_key + None, sheets.open_by_key, sheet_key ) # Get the worksheet worksheet = await loop.run_in_executor( - None, - spreadsheet.worksheet_by_title, - self._config.draft_sheet_worksheet + None, spreadsheet.worksheet_by_title, self._config.draft_sheet_worksheet ) # Sort picks by overall to find range bounds @@ -180,7 +171,7 @@ class DraftSheetService(SheetsService): # Build a 2D array for the entire range (sparse - empty rows for missing picks) # Row index 0 = min_overall, row index N = max_overall num_rows = max_overall - min_overall + 1 - batch_data: List[List[str]] = [['', '', '', ''] for _ in range(num_rows)] + batch_data: List[List[str]] = [["", "", "", ""] for _ in range(num_rows)] # Populate the batch data array for overall, orig_owner, owner, player_name, swar in sorted_picks: @@ -193,23 +184,23 @@ class DraftSheetService(SheetsService): end_column = chr(ord(start_column) + 3) # 4 columns: D -> G end_row = max_overall + 1 - cell_range = f'{start_column}{start_row}:{end_column}{end_row}' + cell_range = f"{start_column}{start_row}:{end_column}{end_row}" self.logger.info( f"Writing {len(picks)} picks in single batch to range {cell_range}", - season=season + season=season, ) # Write all picks in a single API call await loop.run_in_executor( None, - lambda: worksheet.update_values(crange=cell_range, values=batch_data) + lambda: worksheet.update_values(crange=cell_range, values=batch_data), ) self.logger.info( f"Batch write complete: {len(picks)} picks written successfully", season=season, - total_picks=len(picks) + total_picks=len(picks), ) return (len(picks), 0) @@ -218,10 +209,7 @@ class DraftSheetService(SheetsService): return (0, len(picks)) async def clear_picks_range( - self, - season: int, - start_overall: int = 1, - end_overall: int = 512 + self, season: int, start_overall: int = 1, end_overall: int = 512 ) -> bool: """ Clear a range of picks from the draft sheet. @@ -246,23 +234,19 @@ class DraftSheetService(SheetsService): return False try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # Get pygsheets client sheets = await loop.run_in_executor(None, self._get_client) # Open the draft sheet by key spreadsheet = await loop.run_in_executor( - None, - sheets.open_by_key, - sheet_key + None, sheets.open_by_key, sheet_key ) # Get the worksheet worksheet = await loop.run_in_executor( - None, - spreadsheet.worksheet_by_title, - self._config.draft_sheet_worksheet + None, spreadsheet.worksheet_by_title, self._config.draft_sheet_worksheet ) # Calculate range (4 columns: D through G) @@ -273,24 +257,23 @@ class DraftSheetService(SheetsService): # Convert start column letter to end column (D -> G for 4 columns) end_column = chr(ord(start_column) + 3) - cell_range = f'{start_column}{start_row}:{end_column}{end_row}' + cell_range = f"{start_column}{start_row}:{end_column}{end_row}" # Clear the range by setting empty values # We create a 2D array of empty strings num_rows = end_row - start_row + 1 - empty_data = [['', '', '', ''] for _ in range(num_rows)] + empty_data = [["", "", "", ""] for _ in range(num_rows)] await loop.run_in_executor( None, lambda: worksheet.update_values( - crange=f'{start_column}{start_row}', - values=empty_data - ) + crange=f"{start_column}{start_row}", values=empty_data + ), ) self.logger.info( f"Cleared picks {start_overall}-{end_overall} from draft sheet", - season=season + season=season, ) return True diff --git a/services/scorebug_service.py b/services/scorebug_service.py index cac00ee..f19494f 100644 --- a/services/scorebug_service.py +++ b/services/scorebug_service.py @@ -3,6 +3,7 @@ Scorebug Service Handles reading live game data from Google Sheets scorecards for real-time score displays. """ + import asyncio from typing import Dict, Any, Optional import pygsheets @@ -16,30 +17,32 @@ class ScorebugData: """Data class for scorebug information.""" def __init__(self, data: Dict[str, Any]): - self.away_team_id = data.get('away_team_id', 1) - self.home_team_id = data.get('home_team_id', 1) - self.header = data.get('header', '') - self.away_score = data.get('away_score', 0) - self.home_score = data.get('home_score', 0) - self.which_half = data.get('which_half', '') - self.inning = data.get('inning', 1) - self.is_final = data.get('is_final', False) - self.outs = data.get('outs', 0) - self.win_percentage = data.get('win_percentage', 50.0) + self.away_team_id = data.get("away_team_id", 1) + self.home_team_id = data.get("home_team_id", 1) + self.header = data.get("header", "") + self.away_score = data.get("away_score", 0) + self.home_score = data.get("home_score", 0) + self.which_half = data.get("which_half", "") + self.inning = data.get("inning", 1) + self.is_final = data.get("is_final", False) + self.outs = data.get("outs", 0) + self.win_percentage = data.get("win_percentage", 50.0) # Current matchup information - self.pitcher_name = data.get('pitcher_name', '') - self.pitcher_url = data.get('pitcher_url', '') - self.pitcher_stats = data.get('pitcher_stats', '') - self.batter_name = data.get('batter_name', '') - self.batter_url = data.get('batter_url', '') - self.batter_stats = data.get('batter_stats', '') - self.on_deck_name = data.get('on_deck_name', '') - self.in_hole_name = data.get('in_hole_name', '') + self.pitcher_name = data.get("pitcher_name", "") + self.pitcher_url = data.get("pitcher_url", "") + self.pitcher_stats = data.get("pitcher_stats", "") + self.batter_name = data.get("batter_name", "") + self.batter_url = data.get("batter_url", "") + self.batter_stats = data.get("batter_stats", "") + self.on_deck_name = data.get("on_deck_name", "") + self.in_hole_name = data.get("in_hole_name", "") # Additional data - self.runners = data.get('runners', []) # [Catcher, On First, On Second, On Third] - self.summary = data.get('summary', []) # Play-by-play summary lines + self.runners = data.get( + "runners", [] + ) # [Catcher, On First, On Second, On Third] + self.summary = data.get("summary", []) # Play-by-play summary lines @property def score_line(self) -> str: @@ -79,12 +82,10 @@ class ScorebugService(SheetsService): credentials_path: Path to service account credentials JSON """ super().__init__(credentials_path) - self.logger = get_contextual_logger(f'{__name__}.ScorebugService') + self.logger = get_contextual_logger(f"{__name__}.ScorebugService") async def read_scorebug_data( - self, - sheet_url_or_key: str, - full_length: bool = True + self, sheet_url_or_key: str, full_length: bool = True ) -> ScorebugData: """ Read live scorebug data from Google Sheets scorecard. @@ -107,24 +108,28 @@ class ScorebugService(SheetsService): scorecard = await self.open_scorecard(sheet_url_or_key) self.logger.debug(f" ✅ Scorecard opened successfully") - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # Get Scorebug tab scorebug_tab = await loop.run_in_executor( - None, - scorecard.worksheet_by_title, - 'Scorebug' + None, scorecard.worksheet_by_title, "Scorebug" ) # Read all data from B2:S20 for efficiency all_data = await loop.run_in_executor( None, - lambda: scorebug_tab.get_values('B2', 'S20', include_tailing_empty_rows=True) + lambda: scorebug_tab.get_values( + "B2", "S20", include_tailing_empty_rows=True + ), ) self.logger.debug(f"📊 Raw scorebug data dimensions: {len(all_data)} rows") - self.logger.debug(f"📊 First row length: {len(all_data[0]) if all_data else 0} columns") - self.logger.debug(f"📊 Reading from range B2:S20 (columns B-S = indices 0-17 in data)") + self.logger.debug( + f"📊 First row length: {len(all_data[0]) if all_data else 0} columns" + ) + self.logger.debug( + f"📊 Reading from range B2:S20 (columns B-S = indices 0-17 in data)" + ) self.logger.debug(f"📊 Raw data structure (all rows):") for idx, row in enumerate(all_data): self.logger.debug(f" Row {idx} (Sheet row {idx + 2}): {row}") @@ -133,8 +138,13 @@ class ScorebugService(SheetsService): # This corresponds to columns B-G (indices 0-5 in all_data) # Rows 2-8 in sheet (indices 0-6 in all_data) game_state = [ - all_data[0][:6], all_data[1][:6], all_data[2][:6], all_data[3][:6], - all_data[4][:6], all_data[5][:6], all_data[6][:6] + all_data[0][:6], + all_data[1][:6], + all_data[2][:6], + all_data[3][:6], + all_data[4][:6], + all_data[5][:6], + all_data[6][:6], ] self.logger.debug(f"🎮 Extracted game_state (B2:G8):") @@ -145,12 +155,24 @@ class ScorebugService(SheetsService): # game_state[3] is away team row (Sheet row 5), game_state[4] is home team row (Sheet row 6) # First column (index 0) contains the team ID - this is column B in the sheet self.logger.debug(f"🏟️ Extracting team IDs from game_state:") - self.logger.debug(f" Away team row: game_state[3] = Sheet row 5, column B (index 0)") - self.logger.debug(f" Home team row: game_state[4] = Sheet row 6, column B (index 0)") + self.logger.debug( + f" Away team row: game_state[3] = Sheet row 5, column B (index 0)" + ) + self.logger.debug( + f" Home team row: game_state[4] = Sheet row 6, column B (index 0)" + ) try: - away_team_id_raw = game_state[3][0] if len(game_state) > 3 and len(game_state[3]) > 0 else None - home_team_id_raw = game_state[4][0] if len(game_state) > 4 and len(game_state[4]) > 0 else None + away_team_id_raw = ( + game_state[3][0] + if len(game_state) > 3 and len(game_state[3]) > 0 + else None + ) + home_team_id_raw = ( + game_state[4][0] + if len(game_state) > 4 and len(game_state[4]) > 0 + else None + ) self.logger.debug(f" Raw away team ID value: '{away_team_id_raw}'") self.logger.debug(f" Raw home team ID value: '{home_team_id_raw}'") @@ -158,61 +180,97 @@ class ScorebugService(SheetsService): away_team_id = int(away_team_id_raw) if away_team_id_raw else None home_team_id = int(home_team_id_raw) if home_team_id_raw else None - self.logger.debug(f" ✅ Parsed team IDs - Away: {away_team_id}, Home: {home_team_id}") + self.logger.debug( + f" ✅ Parsed team IDs - Away: {away_team_id}, Home: {home_team_id}" + ) if away_team_id is None or home_team_id is None: - raise ValueError(f'Team IDs not found in scorebug (away: {away_team_id}, home: {home_team_id})') + raise ValueError( + f"Team IDs not found in scorebug (away: {away_team_id}, home: {home_team_id})" + ) except (ValueError, IndexError) as e: self.logger.error(f"❌ Failed to parse team IDs from scorebug: {e}") - raise ValueError(f'Could not extract team IDs from scorecard') + raise ValueError(f"Could not extract team IDs from scorecard") # Parse game state self.logger.debug(f"📝 Parsing header from game_state[0][0] (Sheet B2):") - header = game_state[0][0] if game_state[0] else '' - is_final = header[-5:] == 'FINAL' if header else False + header = game_state[0][0] if game_state[0] else "" + is_final = header[-5:] == "FINAL" if header else False self.logger.debug(f" Header value: '{header}'") self.logger.debug(f" Is Final: {is_final}") # Parse scores with validation self.logger.debug(f"⚾ Parsing scores:") - self.logger.debug(f" Away score: game_state[3][2] (Sheet row 5, column D)") - self.logger.debug(f" Home score: game_state[4][2] (Sheet row 6, column D)") + self.logger.debug( + f" Away score: game_state[3][2] (Sheet row 5, column D)" + ) + self.logger.debug( + f" Home score: game_state[4][2] (Sheet row 6, column D)" + ) try: - away_score_raw = game_state[3][2] if len(game_state) > 3 and len(game_state[3]) > 2 else '0' - self.logger.debug(f" Raw away score value: '{away_score_raw}' (type: {type(away_score_raw).__name__})") - away_score = int(away_score_raw) if away_score_raw != '' else 0 + away_score_raw = ( + game_state[3][2] + if len(game_state) > 3 and len(game_state[3]) > 2 + else "0" + ) + self.logger.debug( + f" Raw away score value: '{away_score_raw}' (type: {type(away_score_raw).__name__})" + ) + away_score = int(away_score_raw) if away_score_raw != "" else 0 self.logger.debug(f" ✅ Parsed away score: {away_score}") except (ValueError, IndexError) as e: self.logger.warning(f" ⚠️ Failed to parse away score: {e}") away_score = 0 try: - home_score_raw = game_state[4][2] if len(game_state) > 4 and len(game_state[4]) > 2 else '0' - self.logger.debug(f" Raw home score value: '{home_score_raw}' (type: {type(home_score_raw).__name__})") - home_score = int(home_score_raw) if home_score_raw != '' else 0 + home_score_raw = ( + game_state[4][2] + if len(game_state) > 4 and len(game_state[4]) > 2 + else "0" + ) + self.logger.debug( + f" Raw home score value: '{home_score_raw}' (type: {type(home_score_raw).__name__})" + ) + home_score = int(home_score_raw) if home_score_raw != "" else 0 self.logger.debug(f" ✅ Parsed home score: {home_score}") except (ValueError, IndexError) as e: self.logger.warning(f" ⚠️ Failed to parse home score: {e}") home_score = 0 try: - inning_raw = game_state[3][5] if len(game_state) > 3 and len(game_state[3]) > 5 else '0' - self.logger.debug(f" Raw inning value: '{inning_raw}' (type: {type(inning_raw).__name__})") - inning = int(inning_raw) if inning_raw != '' else 1 + inning_raw = ( + game_state[3][5] + if len(game_state) > 3 and len(game_state[3]) > 5 + else "0" + ) + self.logger.debug( + f" Raw inning value: '{inning_raw}' (type: {type(inning_raw).__name__})" + ) + inning = int(inning_raw) if inning_raw != "" else 1 self.logger.debug(f" ✅ Parsed inning: {inning}") except (ValueError, IndexError) as e: self.logger.warning(f" ⚠️ Failed to parse home score: {e}") inning = 1 - self.logger.debug(f"⏱️ Parsing game state from game_state[3][4] (Sheet row 5, column F):") - which_half = game_state[3][4] if len(game_state) > 3 and len(game_state[3]) > 4 else '' + self.logger.debug( + f"⏱️ Parsing game state from game_state[3][4] (Sheet row 5, column F):" + ) + which_half = ( + game_state[3][4] + if len(game_state) > 3 and len(game_state[3]) > 4 + else "" + ) self.logger.debug(f" Which half value: '{which_half}'") # Parse outs from all_data[4][4] (Sheet F6 - columns start at B, so F=index 4) self.logger.debug(f"🔢 Parsing outs from F6 (all_data[4][4]):") try: - outs_raw = all_data[4][4] if len(all_data) > 4 and len(all_data[4]) > 4 else '0' + outs_raw = ( + all_data[4][4] + if len(all_data) > 4 and len(all_data[4]) > 4 + else "0" + ) self.logger.debug(f" Raw outs value: '{outs_raw}'") # Handle "2" or any number outs = int(outs_raw) if outs_raw and str(outs_raw).strip() else 0 @@ -232,34 +290,42 @@ class ScorebugService(SheetsService): ] # Pitcher: matchups[0][0]=name, [1]=URL, [2]=stats - pitcher_name = matchups[0][0] if len(matchups[0]) > 0 else '' - pitcher_url = matchups[0][1] if len(matchups[0]) > 1 else '' - pitcher_stats = matchups[0][2] if len(matchups[0]) > 2 else '' - self.logger.debug(f" Pitcher: {pitcher_name} | {pitcher_stats} | {pitcher_url}") + pitcher_name = matchups[0][0] if len(matchups[0]) > 0 else "" + pitcher_url = matchups[0][1] if len(matchups[0]) > 1 else "" + pitcher_stats = matchups[0][2] if len(matchups[0]) > 2 else "" + self.logger.debug( + f" Pitcher: {pitcher_name} | {pitcher_stats} | {pitcher_url}" + ) # Batter: matchups[1][0]=name, [1]=URL, [2]=stats, [3]=order, [4]=position - batter_name = matchups[1][0] if len(matchups[1]) > 0 else '' - batter_url = matchups[1][1] if len(matchups[1]) > 1 else '' - batter_stats = matchups[1][2] if len(matchups[1]) > 2 else '' - self.logger.debug(f" Batter: {batter_name} | {batter_stats} | {batter_url}") + batter_name = matchups[1][0] if len(matchups[1]) > 0 else "" + batter_url = matchups[1][1] if len(matchups[1]) > 1 else "" + batter_stats = matchups[1][2] if len(matchups[1]) > 2 else "" + self.logger.debug( + f" Batter: {batter_name} | {batter_stats} | {batter_url}" + ) # On Deck: matchups[2][0]=name - on_deck_name = matchups[2][0] if len(matchups[2]) > 0 else '' - on_deck_url = matchups[2][1] if len(matchups[2]) > 1 else '' + on_deck_name = matchups[2][0] if len(matchups[2]) > 0 else "" + on_deck_url = matchups[2][1] if len(matchups[2]) > 1 else "" self.logger.debug(f" On Deck: {on_deck_name}") # In Hole: matchups[3][0]=name - in_hole_name = matchups[3][0] if len(matchups[3]) > 0 else '' - in_hole_url = matchups[3][1] if len(matchups[3]) > 1 else '' + in_hole_name = matchups[3][0] if len(matchups[3]) > 0 else "" + in_hole_url = matchups[3][1] if len(matchups[3]) > 1 else "" self.logger.debug(f" In Hole: {in_hole_name}") # Parse win percentage from all_data[6][2] (Sheet D8 - row 8, column D) self.logger.debug(f"📈 Parsing win percentage from D8 (all_data[6][2]):") try: - win_pct_raw = all_data[6][2] if len(all_data) > 6 and len(all_data[6]) > 2 else '50%' + win_pct_raw = ( + all_data[6][2] + if len(all_data) > 6 and len(all_data[6]) > 2 + else "50%" + ) self.logger.debug(f" Raw win percentage value: '{win_pct_raw}'") # Remove % sign if present and convert to float - win_pct_str = str(win_pct_raw).replace('%', '').strip() + win_pct_str = str(win_pct_raw).replace("%", "").strip() win_percentage = float(win_pct_str) if win_pct_str else 50.0 self.logger.debug(f" ✅ Parsed win percentage: {win_percentage}%") except (ValueError, IndexError, AttributeError) as e: @@ -281,10 +347,10 @@ class ScorebugService(SheetsService): # Each runner is [name, URL] self.logger.debug(f"🏃 Extracting runners from K11:L14:") runners = [ - all_data[9][9:11] if len(all_data) > 9 else [], # Catcher (row 11) + all_data[9][9:11] if len(all_data) > 9 else [], # Catcher (row 11) all_data[10][9:11] if len(all_data) > 10 else [], # On First (row 12) all_data[11][9:11] if len(all_data) > 11 else [], # On Second (row 13) - all_data[12][9:11] if len(all_data) > 12 else [] # On Third (row 14) + all_data[12][9:11] if len(all_data) > 12 else [], # On Third (row 14) ] self.logger.debug(f" Catcher: {runners[0]}") self.logger.debug(f" On First: {runners[1]}") @@ -308,28 +374,30 @@ class ScorebugService(SheetsService): self.logger.debug(f"✅ Scorebug data extraction complete!") - scorebug_data = ScorebugData({ - 'away_team_id': away_team_id, - 'home_team_id': home_team_id, - 'header': header, - 'away_score': away_score, - 'home_score': home_score, - 'which_half': which_half, - 'inning': inning, - 'is_final': is_final, - 'outs': outs, - 'win_percentage': win_percentage, - 'pitcher_name': pitcher_name, - 'pitcher_url': pitcher_url, - 'pitcher_stats': pitcher_stats, - 'batter_name': batter_name, - 'batter_url': batter_url, - 'batter_stats': batter_stats, - 'on_deck_name': on_deck_name, - 'in_hole_name': in_hole_name, - 'runners': runners, # [Catcher, On First, On Second, On Third], each is [name, URL] - 'summary': summary # Play-by-play lines from R3:S20 - }) + scorebug_data = ScorebugData( + { + "away_team_id": away_team_id, + "home_team_id": home_team_id, + "header": header, + "away_score": away_score, + "home_score": home_score, + "which_half": which_half, + "inning": inning, + "is_final": is_final, + "outs": outs, + "win_percentage": win_percentage, + "pitcher_name": pitcher_name, + "pitcher_url": pitcher_url, + "pitcher_stats": pitcher_stats, + "batter_name": batter_name, + "batter_url": batter_url, + "batter_stats": batter_stats, + "on_deck_name": on_deck_name, + "in_hole_name": in_hole_name, + "runners": runners, # [Catcher, On First, On Second, On Third], each is [name, URL] + "summary": summary, # Play-by-play lines from R3:S20 + } + ) self.logger.debug(f"🎯 Created ScorebugData object:") self.logger.debug(f" Away Team ID: {scorebug_data.away_team_id}") diff --git a/services/sheets_service.py b/services/sheets_service.py index 5695134..a0b313e 100644 --- a/services/sheets_service.py +++ b/services/sheets_service.py @@ -3,6 +3,7 @@ Google Sheets Service Handles reading data from Google Sheets scorecards for game submission. """ + import asyncio from typing import Dict, List, Any, Optional import pygsheets @@ -24,10 +25,11 @@ class SheetsService: """ 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.logger = get_contextual_logger(f"{__name__}.SheetsService") self._sheets_client = None def _get_client(self) -> pygsheets.client.Client: @@ -53,7 +55,16 @@ class SheetsService: return False # Common spreadsheet errors - error_values = ['#N/A', '#REF!', '#VALUE!', '#DIV/0!', '#NUM!', '#NAME?', '#NULL!', '#ERROR!'] + error_values = [ + "#N/A", + "#REF!", + "#VALUE!", + "#DIV/0!", + "#NUM!", + "#NAME?", + "#NULL!", + "#ERROR!", + ] return value.strip() in error_values @staticmethod @@ -68,7 +79,7 @@ class SheetsService: Returns: Integer value or None if invalid """ - if value is None or value == '': + if value is None or value == "": return None # Check for spreadsheet errors @@ -96,16 +107,9 @@ class SheetsService: """ 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 - ) + loop = asyncio.get_running_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 @@ -116,10 +120,7 @@ class SheetsService: "Unable to access scorecard. Is it publicly readable?" ) from e - async def read_setup_data( - self, - scorecard: pygsheets.Spreadsheet - ) -> Dict[str, Any]: + async def read_setup_data(self, scorecard: pygsheets.Spreadsheet) -> Dict[str, Any]: """ Read game metadata from Setup tab. @@ -138,38 +139,27 @@ class SheetsService: - home_manager_name: str """ try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # Get Setup tab setup_tab = await loop.run_in_executor( - None, - scorecard.worksheet_by_title, - 'Setup' + None, scorecard.worksheet_by_title, "Setup" ) # Read version - version = await loop.run_in_executor( - None, - setup_tab.get_value, - 'V35' - ) + 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' - ) + 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] + "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: @@ -177,8 +167,7 @@ class SheetsService: raise SheetsException("Unable to read game setup data") from e async def read_playtable_data( - self, - scorecard: pygsheets.Spreadsheet + self, scorecard: pygsheets.Spreadsheet ) -> List[Dict[str, Any]]: """ Read all plays from Playtable tab. @@ -190,49 +179,101 @@ class SheetsService: List of play dictionaries with field names mapped """ try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # Get Playtable tab playtable = await loop.run_in_executor( - None, - scorecard.worksheet_by_title, - 'Playtable' + None, scorecard.worksheet_by_title, "Playtable" ) # Read play data all_plays = await loop.run_in_executor( - None, - playtable.get_values, - 'B3', - 'BW300' + 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' + "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): + if value != "" and count < len(play_keys): this_data[play_keys[count]] = value # Only include rows with meaningful data (>5 fields) @@ -247,8 +288,7 @@ class SheetsService: raise SheetsException("Unable to read play-by-play data") from e async def read_pitching_decisions( - self, - scorecard: pygsheets.Spreadsheet + self, scorecard: pygsheets.Spreadsheet ) -> List[Dict[str, Any]]: """ Read pitching decisions from Pitcherstats tab. @@ -260,37 +300,51 @@ class SheetsService: List of decision dictionaries with field names mapped """ try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # Get Pitcherstats tab pitching = await loop.run_in_executor( - None, - scorecard.worksheet_by_title, - 'Pitcherstats' + None, scorecard.worksheet_by_title, "Pitcherstats" ) # Read decision data all_decisions = await loop.run_in_executor( - None, - pitching.get_values, - 'B3', - 'O30' + 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' + "pitcher_id", + "rest_ip", + "is_start", + "base_rest", + "extra_rest", + "rest_required", + "win", + "loss", + "is_save", + "hold", + "b_save", + "irunners", + "irunners_scored", + "team_id", ] # Fields that must be integers int_fields = { - 'pitcher_id', 'rest_required', 'win', 'loss', 'is_save', - 'hold', 'b_save', 'irunners', 'irunners_scored', 'team_id' + "pitcher_id", + "rest_required", + "win", + "loss", + "is_save", + "hold", + "b_save", + "irunners", + "irunners_scored", + "team_id", } # Fields that are required and cannot be None - required_fields = {'pitcher_id', 'team_id'} + required_fields = {"pitcher_id", "team_id"} pit_data = [] row_num = 3 # Start at row 3 (B3 in spreadsheet) @@ -310,7 +364,7 @@ class SheetsService: field_name = pit_keys[count] # Skip empty values - if value == '': + if value == "": continue # Check for spreadsheet errors @@ -332,7 +386,7 @@ class SheetsService: # Sanitize integer fields if field_name in int_fields: sanitized = self._sanitize_int_field(value, field_name) - if sanitized is None and value != '': + if sanitized is None and value != "": self.logger.warning( f"Row {row_num}: Invalid integer value '{value}' for field '{field_name}' - skipping row" ) @@ -367,8 +421,7 @@ class SheetsService: raise SheetsException("Unable to read pitching decisions") from e async def read_box_score( - self, - scorecard: pygsheets.Spreadsheet + self, scorecard: pygsheets.Spreadsheet ) -> Dict[str, List[int]]: """ Read box score from Scorecard or Box Score tab. @@ -381,38 +434,28 @@ class SheetsService: [runs, hits, errors] """ try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # Try Scorecard tab first try: sc_tab = await loop.run_in_executor( - None, - scorecard.worksheet_by_title, - 'Scorecard' + None, scorecard.worksheet_by_title, "Scorecard" ) score_table = await loop.run_in_executor( - None, - sc_tab.get_values, - 'BW8', - 'BY9' + 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' + None, scorecard.worksheet_by_title, "Box Score" ) score_table = await loop.run_in_executor( - None, - sc_tab.get_values, - 'T6', - 'V7' + 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] + "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: diff --git a/tasks/draft_monitor.py b/tasks/draft_monitor.py index 1edc4af..88b3cfc 100644 --- a/tasks/draft_monitor.py +++ b/tasks/draft_monitor.py @@ -4,7 +4,8 @@ Draft Monitor Task for Discord Bot v2.0 Automated background task for draft timer monitoring, warnings, and auto-draft. Self-terminates when draft timer is disabled to conserve resources. """ -from datetime import datetime + +from datetime import UTC, datetime import discord from discord.ext import commands, tasks @@ -34,7 +35,7 @@ class DraftMonitorTask: def __init__(self, bot: commands.Bot): self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.DraftMonitorTask') + self.logger = get_contextual_logger(f"{__name__}.DraftMonitorTask") # Warning flags (reset each pick) self.warning_60s_sent = False @@ -101,7 +102,7 @@ class DraftMonitorTask: return # Check if we need to take action - now = datetime.now() + now = datetime.now(UTC) deadline = draft_data.pick_deadline if not deadline: @@ -115,7 +116,9 @@ class DraftMonitorTask: new_interval = self._get_poll_interval(time_remaining) if self.monitor_loop.seconds != new_interval: self.monitor_loop.change_interval(seconds=new_interval) - self.logger.debug(f"Adjusted poll interval to {new_interval}s (time remaining: {time_remaining:.0f}s)") + self.logger.debug( + f"Adjusted poll interval to {new_interval}s (time remaining: {time_remaining:.0f}s)" + ) if time_remaining <= 0: # Timer expired - auto-draft @@ -150,8 +153,7 @@ class DraftMonitorTask: # Get current pick current_pick = await draft_pick_service.get_pick( - config.sba_season, - draft_data.currentpick + config.sba_season, draft_data.currentpick ) if not current_pick or not current_pick.owner: @@ -159,7 +161,7 @@ class DraftMonitorTask: return # Get draft picks cog to check/acquire lock - draft_picks_cog = self.bot.get_cog('DraftPicksCog') + draft_picks_cog = self.bot.get_cog("DraftPicksCog") if not draft_picks_cog: self.logger.error("Could not find DraftPicksCog") @@ -172,7 +174,7 @@ class DraftMonitorTask: # Acquire lock async with draft_picks_cog.pick_lock: - draft_picks_cog.lock_acquired_at = datetime.now() + draft_picks_cog.lock_acquired_at = datetime.now(UTC) draft_picks_cog.lock_acquired_by = None # System auto-draft try: @@ -199,17 +201,20 @@ class DraftMonitorTask: # Get ping channel ping_channel = guild.get_channel(draft_data.ping_channel) if not ping_channel: - self.logger.error(f"Could not find ping channel {draft_data.ping_channel}") + self.logger.error( + f"Could not find ping channel {draft_data.ping_channel}" + ) return # Get team's draft list draft_list = await draft_list_service.get_team_list( - config.sba_season, - current_pick.owner.id + config.sba_season, current_pick.owner.id ) if not draft_list: - self.logger.warning(f"Team {current_pick.owner.abbrev} has no draft list") + self.logger.warning( + f"Team {current_pick.owner.abbrev} has no draft list" + ) await ping_channel.send( content=f"⏰ {current_pick.owner.abbrev} time expired with no draft list - pick skipped" ) @@ -247,11 +252,7 @@ class DraftMonitorTask: # Attempt to draft this player success = await self._attempt_draft_player( - current_pick, - player, - ping_channel, - draft_data, - guild + current_pick, player, ping_channel, draft_data, guild ) if success: @@ -259,7 +260,9 @@ class DraftMonitorTask: f"Auto-drafted {player.name} for {current_pick.owner.abbrev}" ) # Advance to next pick - await draft_service.advance_pick(draft_data.id, draft_data.currentpick) + await draft_service.advance_pick( + draft_data.id, draft_data.currentpick + ) # Post on-clock announcement for next team await self._post_on_clock_announcement(ping_channel, draft_data) # Reset warning flags @@ -284,12 +287,7 @@ class DraftMonitorTask: self.logger.error("Error auto-drafting player", error=e) async def _attempt_draft_player( - self, - draft_pick, - player, - ping_channel, - draft_data, - guild + self, draft_pick, player, ping_channel, draft_data, guild ) -> bool: """ Attempt to draft a specific player. @@ -309,14 +307,18 @@ class DraftMonitorTask: from services.team_service import team_service # Get team roster for cap validation - roster = await team_service.get_team_roster(draft_pick.owner.id, 'current') + roster = await team_service.get_team_roster(draft_pick.owner.id, "current") if not roster: - self.logger.error(f"Could not get roster for team {draft_pick.owner.id}") + self.logger.error( + f"Could not get roster for team {draft_pick.owner.id}" + ) return False # Validate cap space - is_valid, projected_total, cap_limit = await validate_cap_space(roster, player.wara) + is_valid, projected_total, cap_limit = await validate_cap_space( + roster, player.wara + ) if not is_valid: self.logger.debug( @@ -327,8 +329,7 @@ class DraftMonitorTask: # Update draft pick updated_pick = await draft_pick_service.update_pick_selection( - draft_pick.id, - player.id + draft_pick.id, player.id ) if not updated_pick: @@ -338,13 +339,14 @@ class DraftMonitorTask: # Get current league state for dem_week calculation from services.player_service import player_service from services.league_service import league_service + current = await league_service.get_current_state() # Update player team with dem_week set to current.week + 2 for draft picks updated_player = await player_service.update_player_team( player.id, draft_pick.owner.id, - dem_week=current.week + 2 if current else None + dem_week=current.week + 2 if current else None, ) if not updated_player: @@ -357,7 +359,7 @@ class DraftMonitorTask: # Post to ping channel await ping_channel.send( content=f"🤖 AUTO-DRAFT: {draft_pick.owner.abbrev} selects **{player.name}** " - f"(Pick #{draft_pick.overall})" + f"(Pick #{draft_pick.overall})" ) # Post draft card to result channel (same as regular /draft picks) @@ -365,11 +367,14 @@ class DraftMonitorTask: result_channel = guild.get_channel(draft_data.result_channel) if result_channel: from views.draft_views import create_player_draft_card + draft_card = await create_player_draft_card(player, draft_pick) draft_card.set_footer(text="🤖 Auto-drafted from draft list") await result_channel.send(embed=draft_card) else: - self.logger.warning(f"Could not find result channel {draft_data.result_channel}") + self.logger.warning( + f"Could not find result channel {draft_data.result_channel}" + ) return True @@ -403,23 +408,26 @@ class DraftMonitorTask: # Get the new current pick next_pick = await draft_pick_service.get_pick( - config.sba_season, - updated_draft_data.currentpick + config.sba_season, updated_draft_data.currentpick ) if not next_pick or not next_pick.owner: - self.logger.error(f"Could not get pick #{updated_draft_data.currentpick} for announcement") + self.logger.error( + f"Could not get pick #{updated_draft_data.currentpick} for announcement" + ) return # Get recent picks (last 5 completed) recent_picks = await draft_pick_service.get_recent_picks( config.sba_season, updated_draft_data.currentpick - 1, # Start from previous pick - limit=5 + limit=5, ) # Get team roster for sWAR calculation - team_roster = await roster_service.get_team_roster(next_pick.owner.id, "current") + team_roster = await roster_service.get_team_roster( + next_pick.owner.id, "current" + ) roster_swar = team_roster.total_wara if team_roster else 0.0 cap_limit = get_team_salary_cap(next_pick.owner) @@ -427,7 +435,9 @@ class DraftMonitorTask: top_roster_players = [] if team_roster: all_players = team_roster.all_players - sorted_players = sorted(all_players, key=lambda p: p.wara if p.wara else 0.0, reverse=True) + sorted_players = sorted( + all_players, key=lambda p: p.wara if p.wara else 0.0, reverse=True + ) top_roster_players = sorted_players[:5] # Get sheet URL @@ -441,7 +451,7 @@ class DraftMonitorTask: roster_swar=roster_swar, cap_limit=cap_limit, top_roster_players=top_roster_players, - sheet_url=sheet_url + sheet_url=sheet_url, ) # Mention the team's role (using team.lname) @@ -450,10 +460,14 @@ class DraftMonitorTask: if team_role: team_mention = f"{team_role.mention} " else: - self.logger.warning(f"Could not find role for team {next_pick.owner.lname}") + self.logger.warning( + f"Could not find role for team {next_pick.owner.lname}" + ) await ping_channel.send(content=team_mention, embed=embed) - self.logger.info(f"Posted on-clock announcement for pick #{updated_draft_data.currentpick}") + self.logger.info( + f"Posted on-clock announcement for pick #{updated_draft_data.currentpick}" + ) # Reset poll interval to 30s for new pick if self.monitor_loop.seconds != 30: @@ -484,8 +498,7 @@ class DraftMonitorTask: # Get current pick for mention current_pick = await draft_pick_service.get_pick( - config.sba_season, - draft_data.currentpick + config.sba_season, draft_data.currentpick ) if not current_pick or not current_pick.owner: @@ -495,7 +508,7 @@ class DraftMonitorTask: if 55 <= time_remaining <= 60 and not self.warning_60s_sent: await ping_channel.send( content=f"⏰ {current_pick.owner.abbrev} - **60 seconds remaining** " - f"for pick #{current_pick.overall}!" + f"for pick #{current_pick.overall}!" ) self.warning_60s_sent = True self.logger.debug(f"Sent 60s warning for pick #{current_pick.overall}") @@ -504,7 +517,7 @@ class DraftMonitorTask: elif 25 <= time_remaining <= 30 and not self.warning_30s_sent: await ping_channel.send( content=f"⏰ {current_pick.owner.abbrev} - **30 seconds remaining** " - f"for pick #{current_pick.overall}!" + f"for pick #{current_pick.overall}!" ) self.warning_30s_sent = True self.logger.debug(f"Sent 30s warning for pick #{current_pick.overall}") @@ -535,10 +548,14 @@ class DraftMonitorTask: success = await draft_sheet_service.write_pick( season=config.sba_season, overall=draft_pick.overall, - orig_owner_abbrev=draft_pick.origowner.abbrev if draft_pick.origowner else draft_pick.owner.abbrev, + orig_owner_abbrev=( + draft_pick.origowner.abbrev + if draft_pick.origowner + else draft_pick.owner.abbrev + ), owner_abbrev=draft_pick.owner.abbrev, player_name=player.name, - swar=player.wara + swar=player.wara, ) if not success: @@ -546,7 +563,7 @@ class DraftMonitorTask: await self._notify_sheet_failure( ping_channel=ping_channel, pick_overall=draft_pick.overall, - player_name=player.name + player_name=player.name, ) except Exception as e: @@ -554,10 +571,12 @@ class DraftMonitorTask: await self._notify_sheet_failure( ping_channel=ping_channel, pick_overall=draft_pick.overall, - player_name=player.name + player_name=player.name, ) - async def _notify_sheet_failure(self, ping_channel, pick_overall: int, player_name: str) -> None: + async def _notify_sheet_failure( + self, ping_channel, pick_overall: int, player_name: str + ) -> None: """ Post notification to ping channel when sheet write fails. diff --git a/tasks/transaction_freeze.py b/tasks/transaction_freeze.py index 09af9cf..fb93d48 100644 --- a/tasks/transaction_freeze.py +++ b/tasks/transaction_freeze.py @@ -325,7 +325,7 @@ class TransactionFreezeTask: self.logger.warning("Could not get current league state") return - now = datetime.now() + now = datetime.now(UTC) self.logger.info( f"Weekly loop check", datetime=now.isoformat(), @@ -701,10 +701,10 @@ class TransactionFreezeTask: # Build report entry if winning_moves: first_move = winning_moves[0] - # Extract timestamp from moveid (format: Season-XXX-Week-XX-DD-HH:MM:SS) + # Extract timestamp from moveid (format: Season-{season:03d}-Week-{week:02d}-{unix_timestamp}) try: parts = winning_move_id.split("-") - submitted_at = parts[-1] if len(parts) >= 6 else "Unknown" + submitted_at = parts[-1] if len(parts) >= 4 else "Unknown" except Exception: submitted_at = "Unknown" diff --git a/tests/test_config.py b/tests/test_config.py index a90115a..2333b77 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,6 +3,7 @@ Tests for configuration management Ensures configuration loading, validation, and environment handling work correctly. """ + import os import pytest from unittest.mock import patch @@ -12,29 +13,36 @@ from config import BotConfig class TestBotConfig: """Test configuration loading and validation.""" - + def test_config_loads_required_fields(self): """Test that config loads all required fields from environment.""" - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com' - }): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + }, + ): config = BotConfig() - assert config.bot_token == 'test_bot_token' + assert config.bot_token == "test_bot_token" assert config.guild_id == 123456789 - assert config.api_token == 'test_api_token' - assert config.db_url == 'https://api.example.com' - + assert config.api_token == "test_api_token" + assert config.db_url == "https://api.example.com" + def test_config_has_default_values(self): """Test that config provides sensible defaults.""" - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com' - }, clear=True): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + }, + clear=True, + ): # Create config with disabled env file to test true defaults config = BotConfig(_env_file=None) assert config.sba_season == 13 @@ -43,199 +51,246 @@ class TestBotConfig: assert config.sba_color == "a6ce39" assert config.log_level == "INFO" assert config.environment == "development" - assert config.testing is True - + assert config.testing is False + def test_config_overrides_defaults_from_env(self): """Test that environment variables override default values.""" - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com', - 'SBA_SEASON': '15', - 'LOG_LEVEL': 'DEBUG', - 'ENVIRONMENT': 'production', - 'TESTING': 'true' - }): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + "SBA_SEASON": "15", + "LOG_LEVEL": "DEBUG", + "ENVIRONMENT": "production", + "TESTING": "true", + }, + ): config = BotConfig() assert config.sba_season == 15 assert config.log_level == "DEBUG" assert config.environment == "production" assert config.testing is True - + def test_config_ignores_extra_env_vars(self): """Test that extra environment variables are ignored.""" - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com', - 'RANDOM_EXTRA_VAR': 'should_be_ignored', - 'ANOTHER_RANDOM_VAR': 'also_ignored' - }): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + "RANDOM_EXTRA_VAR": "should_be_ignored", + "ANOTHER_RANDOM_VAR": "also_ignored", + }, + ): # Should not raise validation error config = BotConfig() - assert config.bot_token == 'test_bot_token' - + assert config.bot_token == "test_bot_token" + # Extra vars should not be accessible - assert not hasattr(config, 'random_extra_var') - assert not hasattr(config, 'another_random_var') - + assert not hasattr(config, "random_extra_var") + assert not hasattr(config, "another_random_var") + def test_config_converts_string_to_int(self): """Test that guild_id is properly converted from string to int.""" - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '987654321', # String input - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com' - }): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "987654321", # String input + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + }, + ): config = BotConfig() assert config.guild_id == 987654321 assert isinstance(config.guild_id, int) - + def test_config_converts_string_to_bool(self): """Test that boolean fields are properly converted.""" - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com', - 'TESTING': 'false' - }): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + "TESTING": "false", + }, + ): config = BotConfig() assert config.testing is False assert isinstance(config.testing, bool) - - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com', - 'TESTING': '1' - }): + + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + "TESTING": "1", + }, + ): config = BotConfig() assert config.testing is True - + def test_config_case_insensitive(self): """Test that environment variables are case insensitive.""" - with patch.dict(os.environ, { - 'bot_token': 'test_bot_token', # lowercase - 'GUILD_ID': '123456789', # uppercase - 'Api_Token': 'test_api_token', # mixed case - 'db_url': 'https://api.example.com' - }): + with patch.dict( + os.environ, + { + "bot_token": "test_bot_token", # lowercase + "GUILD_ID": "123456789", # uppercase + "Api_Token": "test_api_token", # mixed case + "db_url": "https://api.example.com", + }, + ): config = BotConfig() - assert config.bot_token == 'test_bot_token' - assert config.api_token == 'test_api_token' - assert config.db_url == 'https://api.example.com' - + assert config.bot_token == "test_bot_token" + assert config.api_token == "test_api_token" + assert config.db_url == "https://api.example.com" + def test_is_development_property(self): """Test the is_development property.""" - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com', - 'ENVIRONMENT': 'development' - }): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + "ENVIRONMENT": "development", + }, + ): config = BotConfig() assert config.is_development is True - - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com', - 'ENVIRONMENT': 'production' - }): + + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + "ENVIRONMENT": "production", + }, + ): config = BotConfig() assert config.is_development is False - + def test_is_testing_property(self): """Test the is_testing property.""" - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com', - 'TESTING': 'true' - }): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + "TESTING": "true", + }, + ): config = BotConfig() assert config.is_testing is True - - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com', - 'TESTING': 'false' - }): + + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + "TESTING": "false", + }, + ): config = BotConfig() assert config.is_testing is False class TestConfigValidation: """Test configuration validation and error handling.""" - + def test_missing_required_field_raises_error(self): """Test that missing required fields raise validation errors.""" # Missing BOT_TOKEN - with patch.dict(os.environ, { - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com' - }, clear=True): + with patch.dict( + os.environ, + { + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + }, + clear=True, + ): with pytest.raises(Exception): # Pydantic ValidationError BotConfig(_env_file=None) - + # Missing GUILD_ID - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com' - }, clear=True): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + }, + clear=True, + ): with pytest.raises(Exception): # Pydantic ValidationError BotConfig(_env_file=None) - + def test_invalid_guild_id_raises_error(self): """Test that invalid guild_id values raise validation errors.""" - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': 'not_a_number', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com' - }): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "not_a_number", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + }, + ): with pytest.raises(Exception): # Pydantic ValidationError BotConfig() - + def test_empty_required_field_is_allowed(self): """Test that empty required fields are allowed (Pydantic default behavior).""" - with patch.dict(os.environ, { - 'BOT_TOKEN': '', # Empty string - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com' - }): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "", # Empty string + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + }, + ): # Should not raise - Pydantic allows empty strings by default config = BotConfig() - assert config.bot_token == '' + assert config.bot_token == "" @pytest.fixture def valid_config(): """Provide a valid configuration for testing.""" - with patch.dict(os.environ, { - 'BOT_TOKEN': 'test_bot_token', - 'GUILD_ID': '123456789', - 'API_TOKEN': 'test_api_token', - 'DB_URL': 'https://api.example.com' - }): + with patch.dict( + os.environ, + { + "BOT_TOKEN": "test_bot_token", + "GUILD_ID": "123456789", + "API_TOKEN": "test_api_token", + "DB_URL": "https://api.example.com", + }, + ): return BotConfig() def test_config_fixture(valid_config): """Test that the valid_config fixture works correctly.""" - assert valid_config.bot_token == 'test_bot_token' + assert valid_config.bot_token == "test_bot_token" assert valid_config.guild_id == 123456789 - assert valid_config.api_token == 'test_api_token' - assert valid_config.db_url == 'https://api.example.com' \ No newline at end of file + assert valid_config.api_token == "test_api_token" + assert valid_config.db_url == "https://api.example.com" diff --git a/tests/test_models_custom_command.py b/tests/test_models_custom_command.py index e84ac85..809ec7a 100644 --- a/tests/test_models_custom_command.py +++ b/tests/test_models_custom_command.py @@ -3,6 +3,7 @@ Simplified tests for Custom Command models in Discord Bot v2.0 Testing dataclass models without Pydantic validation. """ + import pytest from datetime import datetime, timedelta, timezone @@ -11,13 +12,13 @@ from models.custom_command import ( CustomCommandCreator, CustomCommandSearchFilters, CustomCommandSearchResult, - CustomCommandStats + CustomCommandStats, ) class TestCustomCommandCreator: """Test the CustomCommandCreator dataclass.""" - + def test_creator_creation(self): """Test creating a creator instance.""" now = datetime.now(timezone.utc) @@ -28,9 +29,9 @@ class TestCustomCommandCreator: display_name="Test User", created_at=now, total_commands=10, - active_commands=5 + active_commands=5, ) - + assert creator.id == 1 assert creator.discord_id == 12345 assert creator.username == "testuser" @@ -38,7 +39,7 @@ class TestCustomCommandCreator: assert creator.created_at == now assert creator.total_commands == 10 assert creator.active_commands == 5 - + def test_creator_optional_fields(self): """Test creator with None display_name.""" now = datetime.now(timezone.utc) @@ -49,9 +50,9 @@ class TestCustomCommandCreator: display_name=None, created_at=now, total_commands=0, - active_commands=0 + active_commands=0, ) - + assert creator.display_name is None assert creator.total_commands == 0 assert creator.active_commands == 0 @@ -59,7 +60,7 @@ class TestCustomCommandCreator: class TestCustomCommand: """Test the CustomCommand dataclass.""" - + @pytest.fixture def sample_creator(self) -> CustomCommandCreator: """Fixture providing a sample creator.""" @@ -70,9 +71,9 @@ class TestCustomCommand: display_name="Test User", created_at=datetime.now(timezone.utc), total_commands=5, - active_commands=5 + active_commands=5, ) - + def test_command_basic_creation(self, sample_creator: CustomCommandCreator): """Test creating a basic command.""" now = datetime.now(timezone.utc) @@ -88,9 +89,9 @@ class TestCustomCommand: use_count=0, warning_sent=False, is_active=True, - tags=None + tags=None, ) - + assert command.id == 1 assert command.name == "hello" assert command.content == "Hello, world!" @@ -102,13 +103,13 @@ class TestCustomCommand: assert command.tags is None assert command.is_active is True assert command.warning_sent is False - + def test_command_with_optional_fields(self, sample_creator: CustomCommandCreator): """Test command with all optional fields.""" now = datetime.now(timezone.utc) last_used = now - timedelta(hours=1) updated = now - timedelta(minutes=30) - + command = CustomCommand( id=1, name="advanced", @@ -121,19 +122,19 @@ class TestCustomCommand: use_count=25, warning_sent=True, is_active=True, - tags=["fun", "utility"] + tags=["fun", "utility"], ) - + assert command.use_count == 25 assert command.last_used == last_used assert command.updated_at == updated assert command.tags == ["fun", "utility"] assert command.warning_sent is True - + def test_days_since_last_use_property(self, sample_creator: CustomCommandCreator): """Test days since last use calculation.""" now = datetime.now(timezone.utc) - + # Command used 5 days ago command = CustomCommand( id=1, @@ -147,17 +148,21 @@ class TestCustomCommand: use_count=1, warning_sent=False, is_active=True, - tags=None + tags=None, ) - + # Mock datetime.utcnow for consistent testing with pytest.MonkeyPatch().context() as m: - m.setattr('models.custom_command.datetime', type('MockDateTime', (), { - 'utcnow': lambda: now, - 'now': lambda: now - })) + m.setattr( + "models.custom_command.datetime", + type( + "MockDateTime", + (), + {"utcnow": lambda: now, "now": lambda tz=None: now}, + ), + ) assert command.days_since_last_use == 5 - + # Command never used unused_command = CustomCommand( id=2, @@ -171,15 +176,15 @@ class TestCustomCommand: use_count=0, warning_sent=False, is_active=True, - tags=None + tags=None, ) - + assert unused_command.days_since_last_use is None - + def test_popularity_score_calculation(self, sample_creator: CustomCommandCreator): """Test popularity score calculation.""" now = datetime.now(timezone.utc) - + # Test with recent usage recent_command = CustomCommand( id=1, @@ -193,18 +198,22 @@ class TestCustomCommand: use_count=50, warning_sent=False, is_active=True, - tags=None + tags=None, ) - + with pytest.MonkeyPatch().context() as m: - m.setattr('models.custom_command.datetime', type('MockDateTime', (), { - 'utcnow': lambda: now, - 'now': lambda: now - })) + m.setattr( + "models.custom_command.datetime", + type( + "MockDateTime", + (), + {"utcnow": lambda: now, "now": lambda tz=None: now}, + ), + ) score = recent_command.popularity_score assert 0 <= score <= 15 # Can be higher due to recency bonus assert score > 0 # Should have some score due to usage - + # Test with no usage unused_command = CustomCommand( id=2, @@ -218,19 +227,19 @@ class TestCustomCommand: use_count=0, warning_sent=False, is_active=True, - tags=None + tags=None, ) - + assert unused_command.popularity_score == 0 class TestCustomCommandSearchFilters: """Test the search filters dataclass.""" - + def test_default_filters(self): """Test default filter values.""" filters = CustomCommandSearchFilters() - + assert filters.name_contains is None assert filters.creator_id is None assert filters.creator_name is None @@ -240,7 +249,7 @@ class TestCustomCommandSearchFilters: assert filters.is_active is True # Note: sort_by, sort_desc, page, page_size have Field objects as defaults # due to mixed dataclass/Pydantic usage - skipping specific value tests - + def test_custom_filters(self): """Test creating filters with custom values.""" filters = CustomCommandSearchFilters( @@ -250,9 +259,9 @@ class TestCustomCommandSearchFilters: sort_by="popularity", sort_desc=True, page=2, - page_size=10 + page_size=10, ) - + assert filters.name_contains == "test" assert filters.creator_name == "user123" assert filters.min_uses == 5 @@ -264,7 +273,7 @@ class TestCustomCommandSearchFilters: class TestCustomCommandSearchResult: """Test the search result dataclass.""" - + @pytest.fixture def sample_commands(self) -> list[CustomCommand]: """Fixture providing sample commands.""" @@ -275,9 +284,9 @@ class TestCustomCommandSearchResult: created_at=datetime.now(timezone.utc), display_name=None, total_commands=3, - active_commands=3 + active_commands=3, ) - + now = datetime.now(timezone.utc) return [ CustomCommand( @@ -292,11 +301,11 @@ class TestCustomCommandSearchResult: use_count=0, warning_sent=False, is_active=True, - tags=None + tags=None, ) for i in range(3) ] - + def test_search_result_creation(self, sample_commands: list[CustomCommand]): """Test creating a search result.""" result = CustomCommandSearchResult( @@ -305,16 +314,16 @@ class TestCustomCommandSearchResult: page=1, page_size=20, total_pages=1, - has_more=False + has_more=False, ) - + assert result.commands == sample_commands assert result.total_count == 10 assert result.page == 1 assert result.page_size == 20 assert result.total_pages == 1 assert result.has_more is False - + def test_search_result_properties(self): """Test search result calculated properties.""" result = CustomCommandSearchResult( @@ -323,16 +332,16 @@ class TestCustomCommandSearchResult: page=2, page_size=20, total_pages=3, - has_more=True + has_more=True, ) - + assert result.start_index == 21 # (2-1) * 20 + 1 - assert result.end_index == 40 # min(2 * 20, 47) + assert result.end_index == 40 # min(2 * 20, 47) class TestCustomCommandStats: """Test the statistics dataclass.""" - + def test_stats_creation(self): """Test creating statistics.""" creator = CustomCommandCreator( @@ -342,9 +351,9 @@ class TestCustomCommandStats: created_at=datetime.now(timezone.utc), display_name=None, total_commands=50, - active_commands=45 + active_commands=45, ) - + command = CustomCommand( id=1, name="hello", @@ -357,9 +366,9 @@ class TestCustomCommandStats: use_count=100, warning_sent=False, is_active=True, - tags=None + tags=None, ) - + stats = CustomCommandStats( total_commands=100, active_commands=95, @@ -369,9 +378,9 @@ class TestCustomCommandStats: most_active_creator=creator, recent_commands_count=15, commands_needing_warning=5, - commands_eligible_for_deletion=2 + commands_eligible_for_deletion=2, ) - + assert stats.total_commands == 100 assert stats.active_commands == 95 assert stats.total_creators == 25 @@ -381,7 +390,7 @@ class TestCustomCommandStats: assert stats.recent_commands_count == 15 assert stats.commands_needing_warning == 5 assert stats.commands_eligible_for_deletion == 2 - + def test_stats_calculated_properties(self): """Test calculated statistics properties.""" # Test with active commands @@ -394,12 +403,12 @@ class TestCustomCommandStats: most_active_creator=None, recent_commands_count=0, commands_needing_warning=0, - commands_eligible_for_deletion=0 + commands_eligible_for_deletion=0, ) - + assert stats.average_uses_per_command == 20.0 # 1000 / 50 assert stats.average_commands_per_creator == 5.0 # 50 / 10 - + # Test with no active commands empty_stats = CustomCommandStats( total_commands=0, @@ -410,16 +419,16 @@ class TestCustomCommandStats: most_active_creator=None, recent_commands_count=0, commands_needing_warning=0, - commands_eligible_for_deletion=0 + commands_eligible_for_deletion=0, ) - + assert empty_stats.average_uses_per_command == 0.0 assert empty_stats.average_commands_per_creator == 0.0 class TestModelIntegration: """Test integration between models.""" - + def test_command_with_creator_relationship(self): """Test the relationship between command and creator.""" now = datetime.now(timezone.utc) @@ -430,9 +439,9 @@ class TestModelIntegration: display_name="Test User", created_at=now, total_commands=3, - active_commands=3 + active_commands=3, ) - + command = CustomCommand( id=1, name="test", @@ -445,25 +454,21 @@ class TestModelIntegration: use_count=0, warning_sent=False, is_active=True, - tags=None + tags=None, ) - + # Verify relationship assert command.creator == creator assert command.creator_id == creator.id assert command.creator.discord_id == 12345 assert command.creator.username == "testuser" - + def test_search_result_with_filters(self): """Test search result creation with filters.""" filters = CustomCommandSearchFilters( - name_contains="test", - min_uses=5, - sort_by="popularity", - page=2, - page_size=10 + name_contains="test", min_uses=5, sort_by="popularity", page=2, page_size=10 ) - + creator = CustomCommandCreator( id=1, discord_id=12345, @@ -471,9 +476,9 @@ class TestModelIntegration: created_at=datetime.now(timezone.utc), display_name=None, total_commands=1, - active_commands=1 + active_commands=1, ) - + commands = [ CustomCommand( id=1, @@ -487,21 +492,21 @@ class TestModelIntegration: use_count=0, warning_sent=False, is_active=True, - tags=None + tags=None, ) ] - + result = CustomCommandSearchResult( commands=commands, total_count=25, page=filters.page, page_size=filters.page_size, total_pages=3, - has_more=True + has_more=True, ) - + assert result.page == 2 assert result.page_size == 10 assert len(result.commands) == 1 assert result.total_pages == 3 - assert result.has_more is True \ No newline at end of file + assert result.has_more is True diff --git a/tests/test_models_help_command.py b/tests/test_models_help_command.py index c717c47..63d8d04 100644 --- a/tests/test_models_help_command.py +++ b/tests/test_models_help_command.py @@ -3,15 +3,16 @@ Tests for Help Command models Validates model creation, validation, and business logic. """ + import pytest -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from pydantic import ValidationError from models.help_command import ( HelpCommand, HelpCommandSearchFilters, HelpCommandSearchResult, - HelpCommandStats + HelpCommandStats, ) @@ -22,133 +23,133 @@ class TestHelpCommandModel: """Test help command creation with minimal required fields.""" help_cmd = HelpCommand( id=1, - name='test-topic', - title='Test Topic', - content='This is test content', - created_by_discord_id='123456789', - created_at=datetime.now() + name="test-topic", + title="Test Topic", + content="This is test content", + created_by_discord_id="123456789", + created_at=datetime.now(UTC), ) assert help_cmd.id == 1 - assert help_cmd.name == 'test-topic' - assert help_cmd.title == 'Test Topic' - assert help_cmd.content == 'This is test content' - assert help_cmd.created_by_discord_id == '123456789' + assert help_cmd.name == "test-topic" + assert help_cmd.title == "Test Topic" + assert help_cmd.content == "This is test content" + assert help_cmd.created_by_discord_id == "123456789" assert help_cmd.is_active is True assert help_cmd.view_count == 0 def test_help_command_creation_with_optional_fields(self): """Test help command creation with all optional fields.""" - now = datetime.now() + now = datetime.now(UTC) help_cmd = HelpCommand( id=2, - name='trading-rules', - title='Trading Rules & Guidelines', - content='Complete trading rules...', - category='rules', - created_by_discord_id='123456789', + name="trading-rules", + title="Trading Rules & Guidelines", + content="Complete trading rules...", + category="rules", + created_by_discord_id="123456789", created_at=now, updated_at=now, - last_modified_by='987654321', + last_modified_by="987654321", is_active=True, view_count=100, - display_order=10 + display_order=10, ) - assert help_cmd.category == 'rules' + assert help_cmd.category == "rules" assert help_cmd.updated_at == now - assert help_cmd.last_modified_by == '987654321' + assert help_cmd.last_modified_by == "987654321" assert help_cmd.view_count == 100 assert help_cmd.display_order == 10 def test_help_command_name_validation(self): """Test help command name validation.""" base_data = { - 'id': 3, - 'title': 'Test', - 'content': 'Content', - 'created_by_discord_id': '123', - 'created_at': datetime.now() + "id": 3, + "title": "Test", + "content": "Content", + "created_by_discord_id": "123", + "created_at": datetime.now(UTC), } # Valid names - valid_names = ['test', 'test-topic', 'test_topic', 'test123', 'abc'] + valid_names = ["test", "test-topic", "test_topic", "test123", "abc"] for name in valid_names: help_cmd = HelpCommand(name=name, **base_data) assert help_cmd.name == name.lower() # Invalid names - too short with pytest.raises(ValidationError): - HelpCommand(name='a', **base_data) + HelpCommand(name="a", **base_data) # Invalid names - too long with pytest.raises(ValidationError): - HelpCommand(name='a' * 33, **base_data) + HelpCommand(name="a" * 33, **base_data) # Invalid names - special characters with pytest.raises(ValidationError): - HelpCommand(name='test@topic', **base_data) + HelpCommand(name="test@topic", **base_data) with pytest.raises(ValidationError): - HelpCommand(name='test topic', **base_data) + HelpCommand(name="test topic", **base_data) def test_help_command_title_validation(self): """Test help command title validation.""" base_data = { - 'id': 4, - 'name': 'test', - 'content': 'Content', - 'created_by_discord_id': '123', - 'created_at': datetime.now() + "id": 4, + "name": "test", + "content": "Content", + "created_by_discord_id": "123", + "created_at": datetime.now(UTC), } # Valid title - help_cmd = HelpCommand(title='Test Topic', **base_data) - assert help_cmd.title == 'Test Topic' + help_cmd = HelpCommand(title="Test Topic", **base_data) + assert help_cmd.title == "Test Topic" # Empty title with pytest.raises(ValidationError): - HelpCommand(title='', **base_data) + HelpCommand(title="", **base_data) # Title too long with pytest.raises(ValidationError): - HelpCommand(title='a' * 201, **base_data) + HelpCommand(title="a" * 201, **base_data) def test_help_command_content_validation(self): """Test help command content validation.""" base_data = { - 'id': 5, - 'name': 'test', - 'title': 'Test', - 'created_by_discord_id': '123', - 'created_at': datetime.now() + "id": 5, + "name": "test", + "title": "Test", + "created_by_discord_id": "123", + "created_at": datetime.now(UTC), } # Valid content - help_cmd = HelpCommand(content='Test content', **base_data) - assert help_cmd.content == 'Test content' + help_cmd = HelpCommand(content="Test content", **base_data) + assert help_cmd.content == "Test content" # Empty content with pytest.raises(ValidationError): - HelpCommand(content='', **base_data) + HelpCommand(content="", **base_data) # Content too long with pytest.raises(ValidationError): - HelpCommand(content='a' * 4001, **base_data) + HelpCommand(content="a" * 4001, **base_data) def test_help_command_category_validation(self): """Test help command category validation.""" base_data = { - 'id': 6, - 'name': 'test', - 'title': 'Test', - 'content': 'Content', - 'created_by_discord_id': '123', - 'created_at': datetime.now() + "id": 6, + "name": "test", + "title": "Test", + "content": "Content", + "created_by_discord_id": "123", + "created_at": datetime.now(UTC), } # Valid categories - valid_categories = ['rules', 'guides', 'resources', 'info', 'faq'] + valid_categories = ["rules", "guides", "resources", "info", "faq"] for category in valid_categories: help_cmd = HelpCommand(category=category, **base_data) assert help_cmd.category == category.lower() @@ -159,28 +160,28 @@ class TestHelpCommandModel: # Invalid category - special characters with pytest.raises(ValidationError): - HelpCommand(category='test@category', **base_data) + HelpCommand(category="test@category", **base_data) def test_help_command_is_deleted_property(self): """Test is_deleted property.""" active = HelpCommand( id=7, - name='active', - title='Active Topic', - content='Content', - created_by_discord_id='123', - created_at=datetime.now(), - is_active=True + name="active", + title="Active Topic", + content="Content", + created_by_discord_id="123", + created_at=datetime.now(UTC), + is_active=True, ) deleted = HelpCommand( id=8, - name='deleted', - title='Deleted Topic', - content='Content', - created_by_discord_id='123', - created_at=datetime.now(), - is_active=False + name="deleted", + title="Deleted Topic", + content="Content", + created_by_discord_id="123", + created_at=datetime.now(UTC), + is_active=False, ) assert active.is_deleted is False @@ -191,24 +192,24 @@ class TestHelpCommandModel: # No updates no_update = HelpCommand( id=9, - name='test', - title='Test', - content='Content', - created_by_discord_id='123', - created_at=datetime.now(), - updated_at=None + name="test", + title="Test", + content="Content", + created_by_discord_id="123", + created_at=datetime.now(UTC), + updated_at=None, ) assert no_update.days_since_update is None # Recent update recent = HelpCommand( id=10, - name='test', - title='Test', - content='Content', - created_by_discord_id='123', - created_at=datetime.now(), - updated_at=datetime.now() - timedelta(days=5) + name="test", + title="Test", + content="Content", + created_by_discord_id="123", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC) - timedelta(days=5), ) assert recent.days_since_update == 5 @@ -216,11 +217,11 @@ class TestHelpCommandModel: """Test days_since_creation property.""" old = HelpCommand( id=11, - name='test', - title='Test', - content='Content', - created_by_discord_id='123', - created_at=datetime.now() - timedelta(days=30) + name="test", + title="Test", + content="Content", + created_by_discord_id="123", + created_at=datetime.now(UTC) - timedelta(days=30), ) assert old.days_since_creation == 30 @@ -229,24 +230,24 @@ class TestHelpCommandModel: # No views no_views = HelpCommand( id=12, - name='test', - title='Test', - content='Content', - created_by_discord_id='123', - created_at=datetime.now(), - view_count=0 + name="test", + title="Test", + content="Content", + created_by_discord_id="123", + created_at=datetime.now(UTC), + view_count=0, ) assert no_views.popularity_score == 0.0 # New topic with views new_popular = HelpCommand( id=13, - name='test', - title='Test', - content='Content', - created_by_discord_id='123', - created_at=datetime.now() - timedelta(days=5), - view_count=50 + name="test", + title="Test", + content="Content", + created_by_discord_id="123", + created_at=datetime.now(UTC) - timedelta(days=5), + view_count=50, ) score = new_popular.popularity_score assert score > 5.0 # Base score (5.0) with new topic bonus (1.5x) @@ -254,12 +255,12 @@ class TestHelpCommandModel: # Old topic with views old_popular = HelpCommand( id=14, - name='test', - title='Test', - content='Content', - created_by_discord_id='123', - created_at=datetime.now() - timedelta(days=100), - view_count=50 + name="test", + title="Test", + content="Content", + created_by_discord_id="123", + created_at=datetime.now(UTC) - timedelta(days=100), + view_count=50, ) old_score = old_popular.popularity_score assert old_score < new_popular.popularity_score # Older topics get penalty @@ -275,7 +276,7 @@ class TestHelpCommandSearchFilters: assert filters.name_contains is None assert filters.category is None assert filters.is_active is True - assert filters.sort_by == 'name' + assert filters.sort_by == "name" assert filters.sort_desc is False assert filters.page == 1 assert filters.page_size == 25 @@ -283,19 +284,19 @@ class TestHelpCommandSearchFilters: def test_search_filters_custom_values(self): """Test search filters with custom values.""" filters = HelpCommandSearchFilters( - name_contains='trading', - category='rules', + name_contains="trading", + category="rules", is_active=False, - sort_by='view_count', + sort_by="view_count", sort_desc=True, page=2, - page_size=50 + page_size=50, ) - assert filters.name_contains == 'trading' - assert filters.category == 'rules' + assert filters.name_contains == "trading" + assert filters.category == "rules" assert filters.is_active is False - assert filters.sort_by == 'view_count' + assert filters.sort_by == "view_count" assert filters.sort_desc is True assert filters.page == 2 assert filters.page_size == 50 @@ -303,14 +304,22 @@ class TestHelpCommandSearchFilters: def test_search_filters_sort_by_validation(self): """Test sort_by field validation.""" # Valid sort fields - valid_sorts = ['name', 'title', 'category', 'created_at', 'updated_at', 'view_count', 'display_order'] + valid_sorts = [ + "name", + "title", + "category", + "created_at", + "updated_at", + "view_count", + "display_order", + ] for sort_field in valid_sorts: filters = HelpCommandSearchFilters(sort_by=sort_field) assert filters.sort_by == sort_field # Invalid sort field with pytest.raises(ValidationError): - HelpCommandSearchFilters(sort_by='invalid_field') + HelpCommandSearchFilters(sort_by="invalid_field") def test_search_filters_page_validation(self): """Test page number validation.""" @@ -353,11 +362,11 @@ class TestHelpCommandSearchResult: help_commands = [ HelpCommand( id=i, - name=f'topic-{i}', - title=f'Topic {i}', - content=f'Content {i}', - created_by_discord_id='123', - created_at=datetime.now() + name=f"topic-{i}", + title=f"Topic {i}", + content=f"Content {i}", + created_by_discord_id="123", + created_at=datetime.now(UTC), ) for i in range(1, 11) ] @@ -368,7 +377,7 @@ class TestHelpCommandSearchResult: page=1, page_size=10, total_pages=5, - has_more=True + has_more=True, ) assert len(result.help_commands) == 10 @@ -386,7 +395,7 @@ class TestHelpCommandSearchResult: page=3, page_size=25, total_pages=4, - has_more=True + has_more=True, ) assert result.start_index == 51 # (3-1) * 25 + 1 @@ -400,7 +409,7 @@ class TestHelpCommandSearchResult: page=3, page_size=25, total_pages=3, - has_more=False + has_more=False, ) assert result.end_index == 55 # min(3 * 25, 55) @@ -412,7 +421,7 @@ class TestHelpCommandSearchResult: page=2, page_size=25, total_pages=4, - has_more=True + has_more=True, ) assert result.end_index == 50 # min(2 * 25, 100) @@ -428,7 +437,7 @@ class TestHelpCommandStats: active_commands=45, total_views=1000, most_viewed_command=None, - recent_commands_count=5 + recent_commands_count=5, ) assert stats.total_commands == 50 @@ -441,12 +450,12 @@ class TestHelpCommandStats: """Test stats with most viewed command.""" most_viewed = HelpCommand( id=1, - name='popular-topic', - title='Popular Topic', - content='Content', - created_by_discord_id='123', - created_at=datetime.now(), - view_count=500 + name="popular-topic", + title="Popular Topic", + content="Content", + created_by_discord_id="123", + created_at=datetime.now(UTC), + view_count=500, ) stats = HelpCommandStats( @@ -454,11 +463,11 @@ class TestHelpCommandStats: active_commands=45, total_views=1000, most_viewed_command=most_viewed, - recent_commands_count=5 + recent_commands_count=5, ) assert stats.most_viewed_command is not None - assert stats.most_viewed_command.name == 'popular-topic' + assert stats.most_viewed_command.name == "popular-topic" assert stats.most_viewed_command.view_count == 500 def test_stats_average_views_per_command(self): @@ -469,7 +478,7 @@ class TestHelpCommandStats: active_commands=40, total_views=800, most_viewed_command=None, - recent_commands_count=5 + recent_commands_count=5, ) assert stats.average_views_per_command == 20.0 # 800 / 40 @@ -480,7 +489,7 @@ class TestHelpCommandStats: active_commands=0, total_views=0, most_viewed_command=None, - recent_commands_count=0 + recent_commands_count=0, ) assert stats.average_views_per_command == 0.0 @@ -492,44 +501,44 @@ class TestHelpCommandFromAPIData: def test_from_api_data_complete(self): """Test from_api_data with complete data.""" api_data = { - 'id': 1, - 'name': 'trading-rules', - 'title': 'Trading Rules & Guidelines', - 'content': 'Complete trading rules...', - 'category': 'rules', - 'created_by_discord_id': '123456789', - 'created_at': '2025-01-01T12:00:00', - 'updated_at': '2025-01-10T15:30:00', - 'last_modified_by': '987654321', - 'is_active': True, - 'view_count': 100, - 'display_order': 10 + "id": 1, + "name": "trading-rules", + "title": "Trading Rules & Guidelines", + "content": "Complete trading rules...", + "category": "rules", + "created_by_discord_id": "123456789", + "created_at": "2025-01-01T12:00:00", + "updated_at": "2025-01-10T15:30:00", + "last_modified_by": "987654321", + "is_active": True, + "view_count": 100, + "display_order": 10, } help_cmd = HelpCommand.from_api_data(api_data) assert help_cmd.id == 1 - assert help_cmd.name == 'trading-rules' - assert help_cmd.title == 'Trading Rules & Guidelines' - assert help_cmd.content == 'Complete trading rules...' - assert help_cmd.category == 'rules' + assert help_cmd.name == "trading-rules" + assert help_cmd.title == "Trading Rules & Guidelines" + assert help_cmd.content == "Complete trading rules..." + assert help_cmd.category == "rules" assert help_cmd.view_count == 100 def test_from_api_data_minimal(self): """Test from_api_data with minimal required data.""" api_data = { - 'id': 2, - 'name': 'simple-topic', - 'title': 'Simple Topic', - 'content': 'Simple content', - 'created_by_discord_id': '123456789', - 'created_at': '2025-01-01T12:00:00' + "id": 2, + "name": "simple-topic", + "title": "Simple Topic", + "content": "Simple content", + "created_by_discord_id": "123456789", + "created_at": "2025-01-01T12:00:00", } help_cmd = HelpCommand.from_api_data(api_data) assert help_cmd.id == 2 - assert help_cmd.name == 'simple-topic' + assert help_cmd.name == "simple-topic" assert help_cmd.category is None assert help_cmd.updated_at is None assert help_cmd.view_count == 0 diff --git a/tests/test_services_draft.py b/tests/test_services_draft.py index 13a0bf9..4270604 100644 --- a/tests/test_services_draft.py +++ b/tests/test_services_draft.py @@ -16,8 +16,9 @@ API Specification Reference: - POST /api/v3/draftlist - Bulk replace team draft list - DELETE /api/v3/draftlist/team/{id} - Clear team draft list """ + import pytest -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch from services.draft_service import DraftService, draft_service @@ -30,11 +31,11 @@ from models.team import Team from models.player import Player from exceptions import APIException - # ============================================================================= # Test Data Helpers # ============================================================================= + def create_draft_data(**overrides) -> dict: """ Create complete draft data matching API response format. @@ -43,14 +44,14 @@ def create_draft_data(**overrides) -> dict: ping_channel, pick_minutes """ base_data = { - 'id': 1, - 'currentpick': 25, - 'timer': True, - 'paused': False, # New field for draft pause feature - 'pick_deadline': (datetime.now() + timedelta(minutes=10)).isoformat(), - 'result_channel': '123456789012345678', # API returns as string - 'ping_channel': '987654321098765432', # API returns as string - 'pick_minutes': 2 + "id": 1, + "currentpick": 25, + "timer": True, + "paused": False, # New field for draft pause feature + "pick_deadline": (datetime.now() + timedelta(minutes=10)).isoformat(), + "result_channel": "123456789012345678", # API returns as string + "ping_channel": "987654321098765432", # API returns as string + "pick_minutes": 2, } base_data.update(overrides) return base_data @@ -59,14 +60,14 @@ def create_draft_data(**overrides) -> dict: def create_team_data(team_id: int, abbrev: str = "TST", **overrides) -> dict: """Create complete team data for nested objects (matches Team model requirements).""" base_data = { - 'id': team_id, - 'abbrev': abbrev, - 'sname': f'{abbrev}', # Required: short name - 'lname': f'{abbrev} Team', # Required: long name - 'season': 12, - 'division_id': 1, - 'gmid': 100 + team_id, - 'thumbnail': f'https://example.com/team{team_id}.png' + "id": team_id, + "abbrev": abbrev, + "sname": f"{abbrev}", # Required: short name + "lname": f"{abbrev} Team", # Required: long name + "season": 12, + "division_id": 1, + "gmid": 100 + team_id, + "thumbnail": f"https://example.com/team{team_id}.png", } base_data.update(overrides) return base_data @@ -75,13 +76,13 @@ def create_team_data(team_id: int, abbrev: str = "TST", **overrides) -> dict: def create_player_data(player_id: int, name: str = "Test Player", **overrides) -> dict: """Create complete player data for nested objects.""" base_data = { - 'id': player_id, - 'name': name, - 'wara': 2.5, - 'season': 12, - 'team_id': 1, - 'image': f'https://example.com/player{player_id}.jpg', - 'pos_1': 'SS' + "id": player_id, + "name": name, + "wara": 2.5, + "season": 12, + "team_id": 1, + "image": f"https://example.com/player{player_id}.jpg", + "pos_1": "SS", } base_data.update(overrides) return base_data @@ -94,7 +95,7 @@ def create_draft_pick_data( round_num: int = 1, player_id: int = None, include_nested: bool = True, - **overrides + **overrides, ) -> dict: """ Create complete draft pick data matching API response format. @@ -102,20 +103,20 @@ def create_draft_pick_data( API returns nested team and player objects when short_output=False. """ base_data = { - 'id': pick_id, - 'season': season, - 'overall': overall, - 'round': round_num, - 'origowner_id': 1, - 'owner_id': 1, - 'player_id': player_id + "id": pick_id, + "season": season, + "overall": overall, + "round": round_num, + "origowner_id": 1, + "owner_id": 1, + "player_id": player_id, } if include_nested: - base_data['origowner'] = create_team_data(1, 'WV') - base_data['owner'] = create_team_data(1, 'WV') + base_data["origowner"] = create_team_data(1, "WV") + base_data["owner"] = create_team_data(1, "WV") if player_id: - base_data['player'] = create_player_data(player_id, f'Player {player_id}') + base_data["player"] = create_player_data(player_id, f"Player {player_id}") base_data.update(overrides) return base_data @@ -127,7 +128,7 @@ def create_draft_list_data( team_id: int = 1, player_id: int = 100, rank: int = 1, - **overrides + **overrides, ) -> dict: """ Create complete draft list entry matching API response format. @@ -135,11 +136,11 @@ def create_draft_list_data( API returns nested team and player objects. """ base_data = { - 'id': entry_id, - 'season': season, - 'rank': rank, - 'team': create_team_data(team_id, 'WV'), - 'player': create_player_data(player_id, f'Target Player {player_id}') + "id": entry_id, + "season": season, + "rank": rank, + "team": create_team_data(team_id, "WV"), + "player": create_player_data(player_id, f"Target Player {player_id}"), } base_data.update(overrides) return base_data @@ -149,6 +150,7 @@ def create_draft_list_data( # DraftService Tests # ============================================================================= + class TestDraftService: """Tests for DraftService - draft configuration and state management.""" @@ -179,7 +181,7 @@ class TestDraftService: - All fields are correctly populated """ mock_data = create_draft_data(currentpick=42, timer=True, pick_minutes=5) - mock_client.get.return_value = {'count': 1, 'draftdata': [mock_data]} + mock_client.get.return_value = {"count": 1, "draftdata": [mock_data]} result = await service.get_draft_data() @@ -188,7 +190,7 @@ class TestDraftService: assert result.currentpick == 42 assert result.timer is True assert result.pick_minutes == 5 - mock_client.get.assert_called_once_with('draftdata', params=None) + mock_client.get.assert_called_once_with("draftdata", params=None) @pytest.mark.asyncio async def test_get_draft_data_not_found(self, service, mock_client): @@ -197,7 +199,7 @@ class TestDraftService: Verifies graceful handling when API returns empty list. """ - mock_client.get.return_value = {'count': 0, 'draftdata': []} + mock_client.get.return_value = {"count": 0, "draftdata": []} result = await service.get_draft_data() @@ -224,10 +226,9 @@ class TestDraftService: Database stores channel IDs as strings, but we need integers for Discord. """ mock_data = create_draft_data( - result_channel='123456789012345678', - ping_channel='987654321098765432' + result_channel="123456789012345678", ping_channel="987654321098765432" ) - mock_client.get.return_value = {'count': 1, 'draftdata': [mock_data]} + mock_client.get.return_value = {"count": 1, "draftdata": [mock_data]} result = await service.get_draft_data() @@ -251,18 +252,14 @@ class TestDraftService: mock_client.patch.return_value = updated_data result = await service.update_draft_data( - draft_id=1, - updates={'currentpick': 50, 'timer': False} + draft_id=1, updates={"currentpick": 50, "timer": False} ) assert result is not None assert result.currentpick == 50 assert result.timer is False mock_client.patch.assert_called_once_with( - 'draftdata', - {'currentpick': 50, 'timer': False}, - 1, - use_query_params=True + "draftdata", {"currentpick": 50, "timer": False}, 1, use_query_params=True ) @pytest.mark.asyncio @@ -274,7 +271,7 @@ class TestDraftService: """ mock_client.patch.return_value = None - result = await service.update_draft_data(draft_id=1, updates={'timer': True}) + result = await service.update_draft_data(draft_id=1, updates={"timer": True}) assert result is None @@ -293,7 +290,7 @@ class TestDraftService: """ # First call gets current draft data for pick_minutes current_data = create_draft_data(pick_minutes=3, timer=False) - mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} + mock_client.get.return_value = {"count": 1, "draftdata": [current_data]} # Second call updates the draft data updated_data = create_draft_data(timer=True) @@ -306,8 +303,8 @@ class TestDraftService: # Verify patch was called with timer=True and a pick_deadline patch_call = mock_client.patch.call_args - assert patch_call[0][1]['timer'] is True - assert 'pick_deadline' in patch_call[0][1] + assert patch_call[0][1]["timer"] is True + assert "pick_deadline" in patch_call[0][1] @pytest.mark.asyncio async def test_set_timer_disable(self, service, mock_client): @@ -327,8 +324,8 @@ class TestDraftService: # Verify pick_deadline is set far in future (690 days) patch_call = mock_client.patch.call_args - deadline = patch_call[0][1]['pick_deadline'] - assert deadline > datetime.now() + timedelta(days=600) + deadline = patch_call[0][1]["pick_deadline"] + assert deadline > datetime.now(UTC) + timedelta(days=600) @pytest.mark.asyncio async def test_set_timer_with_custom_minutes(self, service, mock_client): @@ -343,7 +340,7 @@ class TestDraftService: result = await service.set_timer(draft_id=1, active=True, pick_minutes=10) patch_call = mock_client.patch.call_args - assert patch_call[0][1]['pick_minutes'] == 10 + assert patch_call[0][1]["pick_minutes"] == 10 # ------------------------------------------------------------------------- # advance_pick() tests @@ -359,22 +356,28 @@ class TestDraftService: - Draft data is updated with new currentpick """ # Mock config at the correct import location (inside the method) - with patch('config.get_config') as mock_config: + with patch("config.get_config") as mock_config: config = MagicMock() config.sba_season = 12 config.draft_total_picks = 512 mock_config.return_value = config # Mock draft_pick_service at the module level - with patch('services.draft_pick_service.draft_pick_service') as mock_pick_service: - unfilled_pick = DraftPick(**create_draft_pick_data( - pick_id=26, overall=26, player_id=None, include_nested=False - )) + with patch( + "services.draft_pick_service.draft_pick_service" + ) as mock_pick_service: + unfilled_pick = DraftPick( + **create_draft_pick_data( + pick_id=26, overall=26, player_id=None, include_nested=False + ) + ) mock_pick_service.get_pick = AsyncMock(return_value=unfilled_pick) # Current draft data has timer active - current_data = create_draft_data(currentpick=25, timer=True, pick_minutes=2) - mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} + current_data = create_draft_data( + currentpick=25, timer=True, pick_minutes=2 + ) + mock_client.get.return_value = {"count": 1, "draftdata": [current_data]} # Update returns new state updated_data = create_draft_data(currentpick=26) @@ -392,30 +395,40 @@ class TestDraftService: Verifies picks with player_id are skipped until an empty pick is found. """ - with patch('config.get_config') as mock_config: + with patch("config.get_config") as mock_config: config = MagicMock() config.sba_season = 12 config.draft_total_picks = 512 mock_config.return_value = config - with patch('services.draft_pick_service.draft_pick_service') as mock_pick_service: + with patch( + "services.draft_pick_service.draft_pick_service" + ) as mock_pick_service: # Picks 26-28 are filled, 29 is empty async def get_pick_side_effect(season, overall): if overall <= 28: - return DraftPick(**create_draft_pick_data( - pick_id=overall, overall=overall, player_id=overall * 10, - include_nested=False - )) + return DraftPick( + **create_draft_pick_data( + pick_id=overall, + overall=overall, + player_id=overall * 10, + include_nested=False, + ) + ) else: - return DraftPick(**create_draft_pick_data( - pick_id=overall, overall=overall, player_id=None, - include_nested=False - )) + return DraftPick( + **create_draft_pick_data( + pick_id=overall, + overall=overall, + player_id=None, + include_nested=False, + ) + ) mock_pick_service.get_pick = AsyncMock(side_effect=get_pick_side_effect) current_data = create_draft_data(currentpick=25, timer=True) - mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} + mock_client.get.return_value = {"count": 1, "draftdata": [current_data]} updated_data = create_draft_data(currentpick=29) mock_client.patch.return_value = updated_data @@ -424,7 +437,7 @@ class TestDraftService: # Should have jumped to pick 29 (skipping 26, 27, 28) patch_call = mock_client.patch.call_args - assert patch_call[0][1]['currentpick'] == 29 + assert patch_call[0][1]["currentpick"] == 29 # ------------------------------------------------------------------------- # update_channels() tests @@ -443,13 +456,13 @@ class TestDraftService: result = await service.update_channels( draft_id=1, ping_channel_id=111111111111111111, - result_channel_id=222222222222222222 + result_channel_id=222222222222222222, ) assert result is not None patch_call = mock_client.patch.call_args - assert patch_call[0][1]['ping_channel'] == 111111111111111111 - assert patch_call[0][1]['result_channel'] == 222222222222222222 + assert patch_call[0][1]["ping_channel"] == 111111111111111111 + assert patch_call[0][1]["result_channel"] == 222222222222222222 # ------------------------------------------------------------------------- # pause_draft() tests @@ -477,9 +490,9 @@ class TestDraftService: # Verify PATCH was called with all pause-related updates patch_call = mock_client.patch.call_args patch_data = patch_call[0][1] - assert patch_data['paused'] is True - assert patch_data['timer'] is False - assert 'pick_deadline' in patch_data # Far-future deadline set + assert patch_data["paused"] is True + assert patch_data["timer"] is False + assert "pick_deadline" in patch_data # Far-future deadline set @pytest.mark.asyncio async def test_pause_draft_failure(self, service, mock_client): @@ -523,7 +536,7 @@ class TestDraftService: """ # First call: get_draft_data to fetch pick_minutes current_data = create_draft_data(paused=True, timer=False, pick_minutes=5) - mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} + mock_client.get.return_value = {"count": 1, "draftdata": [current_data]} # Second call: patch returns updated data updated_data = create_draft_data(paused=False, timer=True, pick_minutes=5) @@ -538,9 +551,9 @@ class TestDraftService: # Verify PATCH was called with all resume-related updates patch_call = mock_client.patch.call_args patch_data = patch_call[0][1] - assert patch_data['paused'] is False - assert patch_data['timer'] is True - assert 'pick_deadline' in patch_data # Fresh deadline set + assert patch_data["paused"] is False + assert patch_data["timer"] is True + assert "pick_deadline" in patch_data # Fresh deadline set @pytest.mark.asyncio async def test_resume_draft_failure(self, service, mock_client): @@ -551,7 +564,7 @@ class TestDraftService: """ # First call: get_draft_data succeeds current_data = create_draft_data(paused=True, timer=False) - mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} + mock_client.get.return_value = {"count": 1, "draftdata": [current_data]} # PATCH fails mock_client.patch.return_value = None @@ -569,7 +582,7 @@ class TestDraftService: """ # First call: get_draft_data succeeds current_data = create_draft_data(paused=True, timer=False) - mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]} + mock_client.get.return_value = {"count": 1, "draftdata": [current_data]} # PATCH fails with exception mock_client.patch.side_effect = Exception("API unavailable") @@ -597,7 +610,7 @@ class TestDraftService: # Then resume - timer should be restarted # resume_draft first fetches current data to get pick_minutes - mock_client.get.return_value = {'count': 1, 'draftdata': [paused_data]} + mock_client.get.return_value = {"count": 1, "draftdata": [paused_data]} resumed_data = create_draft_data(paused=False, timer=True) mock_client.patch.return_value = resumed_data @@ -610,6 +623,7 @@ class TestDraftService: # DraftPickService Tests # ============================================================================= + class TestDraftPickService: """Tests for DraftPickService - draft pick CRUD operations.""" @@ -639,9 +653,11 @@ class TestDraftPickService: - Pick is parsed into DraftPick model """ # Use include_nested=False to avoid Team validation complexity - pick_data = create_draft_pick_data(pick_id=42, overall=42, round_num=3, include_nested=False) + pick_data = create_draft_pick_data( + pick_id=42, overall=42, round_num=3, include_nested=False + ) # API returns data under 'draftpicks' key (matches endpoint name) - mock_client.get.return_value = {'count': 1, 'draftpicks': [pick_data]} + mock_client.get.return_value = {"count": 1, "draftpicks": [pick_data]} result = await service.get_pick(season=12, overall=42) @@ -653,10 +669,10 @@ class TestDraftPickService: mock_client.get.assert_called_once() # BaseService calls get(endpoint, params=params) call_kwargs = mock_client.get.call_args[1] - assert 'params' in call_kwargs - call_params = call_kwargs['params'] - assert ('season', '12') in call_params - assert ('overall', '42') in call_params + assert "params" in call_kwargs + call_params = call_kwargs["params"] + assert ("season", "12") in call_params + assert ("overall", "42") in call_params @pytest.mark.asyncio async def test_get_pick_not_found(self, service, mock_client): @@ -665,7 +681,7 @@ class TestDraftPickService: Verifies service returns None for non-existent picks. """ - mock_client.get.return_value = {'count': 0, 'draftpicks': []} + mock_client.get.return_value = {"count": 0, "draftpicks": []} result = await service.get_pick(season=12, overall=999) @@ -685,10 +701,12 @@ class TestDraftPickService: - Multiple picks are returned as list """ picks_data = [ - create_draft_pick_data(pick_id=i, overall=i, round_num=1, include_nested=False) + create_draft_pick_data( + pick_id=i, overall=i, round_num=1, include_nested=False + ) for i in range(1, 4) ] - mock_client.get.return_value = {'count': 3, 'draftpicks': picks_data} + mock_client.get.return_value = {"count": 3, "draftpicks": picks_data} result = await service.get_picks_by_team( season=12, team_id=1, round_start=1, round_end=5 @@ -697,11 +715,11 @@ class TestDraftPickService: assert len(result) == 3 assert all(isinstance(p, DraftPick) for p in result) - call_params = mock_client.get.call_args[1]['params'] - assert ('owner_team_id', '1') in call_params - assert ('pick_round_start', '1') in call_params - assert ('pick_round_end', '5') in call_params - assert ('sort', 'order-asc') in call_params + call_params = mock_client.get.call_args[1]["params"] + assert ("owner_team_id", "1") in call_params + assert ("pick_round_start", "1") in call_params + assert ("pick_round_end", "5") in call_params + assert ("sort", "order-asc") in call_params # ------------------------------------------------------------------------- # get_picks_by_round() tests @@ -715,18 +733,20 @@ class TestDraftPickService: Verifies pick_round_start and pick_round_end are both set to same value. """ picks_data = [ - create_draft_pick_data(pick_id=i, overall=i, round_num=3, include_nested=False) + create_draft_pick_data( + pick_id=i, overall=i, round_num=3, include_nested=False + ) for i in range(33, 49) # Round 3 picks ] - mock_client.get.return_value = {'count': 16, 'draftpicks': picks_data} + mock_client.get.return_value = {"count": 16, "draftpicks": picks_data} result = await service.get_picks_by_round(season=12, round_num=3) assert len(result) == 16 - call_params = mock_client.get.call_args[1]['params'] - assert ('pick_round_start', '3') in call_params - assert ('pick_round_end', '3') in call_params + call_params = mock_client.get.call_args[1]["params"] + assert ("pick_round_start", "3") in call_params + assert ("pick_round_end", "3") in call_params @pytest.mark.asyncio async def test_get_picks_by_round_exclude_taken(self, service, mock_client): @@ -735,12 +755,12 @@ class TestDraftPickService: Verifies player_taken=false filter is applied. """ - mock_client.get.return_value = {'count': 0, 'draftpicks': []} + mock_client.get.return_value = {"count": 0, "draftpicks": []} await service.get_picks_by_round(season=12, round_num=3, include_taken=False) - call_params = mock_client.get.call_args[1]['params'] - assert ('player_taken', 'false') in call_params + call_params = mock_client.get.call_args[1]["params"] + assert ("player_taken", "false") in call_params # ------------------------------------------------------------------------- # get_available_picks() tests @@ -754,18 +774,20 @@ class TestDraftPickService: Verifies player_taken=false filter is always applied. """ picks_data = [ - create_draft_pick_data(pick_id=i, overall=i, player_id=None, include_nested=False) + create_draft_pick_data( + pick_id=i, overall=i, player_id=None, include_nested=False + ) for i in range(50, 55) ] - mock_client.get.return_value = {'count': 5, 'draftpicks': picks_data} + mock_client.get.return_value = {"count": 5, "draftpicks": picks_data} result = await service.get_available_picks(season=12) assert len(result) == 5 assert all(p.player_id is None for p in result) - call_params = mock_client.get.call_args[1]['params'] - assert ('player_taken', 'false') in call_params + call_params = mock_client.get.call_args[1]["params"] + assert ("player_taken", "false") in call_params @pytest.mark.asyncio async def test_get_available_picks_with_range(self, service, mock_client): @@ -774,15 +796,13 @@ class TestDraftPickService: Verifies overall_start and overall_end params are passed. """ - mock_client.get.return_value = {'count': 0, 'draftpicks': []} + mock_client.get.return_value = {"count": 0, "draftpicks": []} - await service.get_available_picks( - season=12, overall_start=100, overall_end=150 - ) + await service.get_available_picks(season=12, overall_start=100, overall_end=150) - call_params = mock_client.get.call_args[1]['params'] - assert ('overall_start', '100') in call_params - assert ('overall_end', '150') in call_params + call_params = mock_client.get.call_args[1]["params"] + assert ("overall_start", "100") in call_params + assert ("overall_end", "150") in call_params # ------------------------------------------------------------------------- # get_recent_picks() tests @@ -800,20 +820,22 @@ class TestDraftPickService: - limit is applied """ picks_data = [ - create_draft_pick_data(pick_id=i, overall=i, player_id=i*10, include_nested=False) + create_draft_pick_data( + pick_id=i, overall=i, player_id=i * 10, include_nested=False + ) for i in range(45, 50) ] - mock_client.get.return_value = {'count': 5, 'draftpicks': picks_data} + mock_client.get.return_value = {"count": 5, "draftpicks": picks_data} result = await service.get_recent_picks(season=12, overall_end=50, limit=5) assert len(result) == 5 - call_params = mock_client.get.call_args[1]['params'] - assert ('overall_end', '50') in call_params # Passed through directly - assert ('player_taken', 'true') in call_params - assert ('sort', 'order-desc') in call_params - assert ('limit', '5') in call_params + call_params = mock_client.get.call_args[1]["params"] + assert ("overall_end", "50") in call_params # Passed through directly + assert ("player_taken", "true") in call_params + assert ("sort", "order-desc") in call_params + assert ("limit", "5") in call_params # ------------------------------------------------------------------------- # get_upcoming_picks() tests @@ -830,19 +852,21 @@ class TestDraftPickService: - limit is applied """ picks_data = [ - create_draft_pick_data(pick_id=i, overall=i, player_id=None, include_nested=False) + create_draft_pick_data( + pick_id=i, overall=i, player_id=None, include_nested=False + ) for i in range(51, 56) ] - mock_client.get.return_value = {'count': 5, 'draftpicks': picks_data} + mock_client.get.return_value = {"count": 5, "draftpicks": picks_data} result = await service.get_upcoming_picks(season=12, overall_start=50, limit=5) assert len(result) == 5 - call_params = mock_client.get.call_args[1]['params'] - assert ('overall_start', '51') in call_params # 50 + 1 - assert ('sort', 'order-asc') in call_params - assert ('limit', '5') in call_params + call_params = mock_client.get.call_args[1]["params"] + assert ("overall_start", "51") in call_params # 50 + 1 + assert ("sort", "order-asc") in call_params + assert ("limit", "5") in call_params # ------------------------------------------------------------------------- # update_pick_selection() tests @@ -877,12 +901,12 @@ class TestDraftPickService: # Verify PATCH was called with full model (not just player_id) patch_call = mock_client.patch.call_args patch_data = patch_call[0][1] - assert patch_data['player_id'] == 999 - assert patch_data['overall'] == 42 - assert patch_data['round'] == 3 - assert patch_data['season'] == 12 - assert patch_data['origowner_id'] == 1 - assert patch_data['owner_id'] == 1 + assert patch_data["player_id"] == 999 + assert patch_data["overall"] == 42 + assert patch_data["round"] == 3 + assert patch_data["season"] == 12 + assert patch_data["origowner_id"] == 1 + assert patch_data["owner_id"] == 1 @pytest.mark.asyncio async def test_update_pick_selection_pick_not_found(self, service, mock_client): @@ -927,8 +951,8 @@ class TestDraftPickService: # Verify full model sent with player_id=None patch_call = mock_client.patch.call_args patch_data = patch_call[0][1] - assert patch_data['player_id'] is None - assert 'overall' in patch_data # Full model required + assert patch_data["player_id"] is None + assert "overall" in patch_data # Full model required @pytest.mark.asyncio async def test_get_skipped_picks_for_team_success(self, service, mock_client): @@ -940,23 +964,29 @@ class TestDraftPickService: """ # Team 5 has two skipped picks (overall 10 and 15) before current pick 25 skipped_pick_1 = create_draft_pick_data( - pick_id=10, overall=10, round_num=1, player_id=None, - owner_team_id=5, include_nested=False + pick_id=10, + overall=10, + round_num=1, + player_id=None, + owner_team_id=5, + include_nested=False, ) skipped_pick_2 = create_draft_pick_data( - pick_id=15, overall=15, round_num=1, player_id=None, - owner_team_id=5, include_nested=False + pick_id=15, + overall=15, + round_num=1, + player_id=None, + owner_team_id=5, + include_nested=False, ) mock_client.get.return_value = { - 'count': 2, - 'picks': [skipped_pick_1, skipped_pick_2] + "count": 2, + "picks": [skipped_pick_1, skipped_pick_2], } result = await service.get_skipped_picks_for_team( - season=12, - team_id=5, - current_overall=25 + season=12, team_id=5, current_overall=25 ) # Verify results @@ -969,11 +999,11 @@ class TestDraftPickService: # Verify API call mock_client.get.assert_called_once() call_args = mock_client.get.call_args - params = call_args[1]['params'] + params = call_args[1]["params"] # Should request picks before current (overall_end=24), owned by team, with no player - assert ('overall_end', '24') in params - assert ('owner_team_id', '5') in params - assert ('player_taken', 'false') in params + assert ("overall_end", "24") in params + assert ("owner_team_id", "5") in params + assert ("player_taken", "false") in params @pytest.mark.asyncio async def test_get_skipped_picks_for_team_none_found(self, service, mock_client): @@ -982,15 +1012,10 @@ class TestDraftPickService: Returns empty list when all prior picks have been made. """ - mock_client.get.return_value = { - 'count': 0, - 'picks': [] - } + mock_client.get.return_value = {"count": 0, "picks": []} result = await service.get_skipped_picks_for_team( - season=12, - team_id=5, - current_overall=25 + season=12, team_id=5, current_overall=25 ) assert result == [] @@ -1005,9 +1030,7 @@ class TestDraftPickService: mock_client.get.side_effect = Exception("API Error") result = await service.get_skipped_picks_for_team( - season=12, - team_id=5, - current_overall=25 + season=12, team_id=5, current_overall=25 ) # Should return empty list on error, not raise @@ -1018,6 +1041,7 @@ class TestDraftPickService: # DraftListService Tests # ============================================================================= + class TestDraftListService: """Tests for DraftListService - auto-draft queue management.""" @@ -1053,7 +1077,7 @@ class TestDraftListService: create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), ] - mock_client.get.return_value = {'count': 3, 'picks': list_data} + mock_client.get.return_value = {"count": 3, "picks": list_data} result = await service.get_team_list(season=12, team_id=1) @@ -1063,11 +1087,11 @@ class TestDraftListService: assert result[1].rank == 2 assert result[2].rank == 3 - call_params = mock_client.get.call_args[1]['params'] - assert ('season', '12') in call_params - assert ('team_id', '1') in call_params + call_params = mock_client.get.call_args[1]["params"] + assert ("season", "12") in call_params + assert ("team_id", "1") in call_params # sort param should NOT be sent (API doesn't support it) - assert not any(p[0] == 'sort' for p in call_params) + assert not any(p[0] == "sort" for p in call_params) @pytest.mark.asyncio async def test_get_team_list_empty(self, service, mock_client): @@ -1076,7 +1100,7 @@ class TestDraftListService: Verifies empty list is returned, not None. """ - mock_client.get.return_value = {'count': 0, 'picks': []} + mock_client.get.return_value = {"count": 0, "picks": []} result = await service.get_team_list(season=12, team_id=1) @@ -1101,7 +1125,7 @@ class TestDraftListService: create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), ] - mock_client.get.return_value = {'count': 2, 'picks': existing_list} + mock_client.get.return_value = {"count": 2, "picks": existing_list} # After POST, return updated list with 3 entries updated_list = existing_list + [ @@ -1109,8 +1133,8 @@ class TestDraftListService: ] # First get returns existing, second get returns updated (for verification) mock_client.get.side_effect = [ - {'count': 2, 'picks': existing_list}, - {'count': 3, 'picks': updated_list} + {"count": 2, "picks": existing_list}, + {"count": 3, "picks": updated_list}, ] mock_client.post.return_value = "Inserted 3 list values" @@ -1124,11 +1148,11 @@ class TestDraftListService: # Verify POST payload structure post_call = mock_client.post.call_args payload = post_call[0][1] - assert 'draft_list' in payload - assert payload['count'] == 3 + assert "draft_list" in payload + assert payload["count"] == 3 # New entry should have rank 3 - new_entry = [e for e in payload['draft_list'] if e['player_id'] == 103][0] - assert new_entry['rank'] == 3 + new_entry = [e for e in payload["draft_list"] if e["player_id"] == 103][0] + assert new_entry["rank"] == 3 @pytest.mark.asyncio async def test_add_to_list_at_position(self, service, mock_client): @@ -1149,8 +1173,8 @@ class TestDraftListService: ] mock_client.get.side_effect = [ - {'count': 2, 'picks': existing_list}, - {'count': 3, 'picks': updated_list} + {"count": 2, "picks": existing_list}, + {"count": 3, "picks": updated_list}, ] mock_client.post.return_value = "Inserted 3 list values" @@ -1164,10 +1188,10 @@ class TestDraftListService: post_call = mock_client.post.call_args payload = post_call[0][1] - entries_by_player = {e['player_id']: e for e in payload['draft_list']} - assert entries_by_player[101]['rank'] == 1 # Unchanged - assert entries_by_player[103]['rank'] == 2 # Inserted - assert entries_by_player[102]['rank'] == 3 # Shifted from 2 to 3 + entries_by_player = {e["player_id"]: e for e in payload["draft_list"]} + assert entries_by_player[101]["rank"] == 1 # Unchanged + assert entries_by_player[103]["rank"] == 2 # Inserted + assert entries_by_player[102]["rank"] == 3 # Shifted from 2 to 3 # ------------------------------------------------------------------------- # remove_player_from_list() tests @@ -1187,7 +1211,7 @@ class TestDraftListService: create_draft_list_data(entry_id=2, rank=2, player_id=102), create_draft_list_data(entry_id=3, rank=3, player_id=103), ] - mock_client.get.return_value = {'count': 3, 'picks': existing_list} + mock_client.get.return_value = {"count": 3, "picks": existing_list} mock_client.post.return_value = "Inserted 2 list values" result = await service.remove_player_from_list( @@ -1200,16 +1224,16 @@ class TestDraftListService: post_call = mock_client.post.call_args payload = post_call[0][1] - assert payload['count'] == 2 - player_ids = [e['player_id'] for e in payload['draft_list']] + assert payload["count"] == 2 + player_ids = [e["player_id"] for e in payload["draft_list"]] assert 102 not in player_ids # Verify ranks are re-normalized - entries = sorted(payload['draft_list'], key=lambda e: e['rank']) - assert entries[0]['player_id'] == 101 - assert entries[0]['rank'] == 1 - assert entries[1]['player_id'] == 103 - assert entries[1]['rank'] == 2 # Was 3, now 2 + entries = sorted(payload["draft_list"], key=lambda e: e["rank"]) + assert entries[0]["player_id"] == 101 + assert entries[0]["rank"] == 1 + assert entries[1]["player_id"] == 103 + assert entries[1]["rank"] == 2 # Was 3, now 2 @pytest.mark.asyncio async def test_remove_player_not_found(self, service, mock_client): @@ -1221,7 +1245,7 @@ class TestDraftListService: existing_list = [ create_draft_list_data(entry_id=1, rank=1, player_id=101), ] - mock_client.get.return_value = {'count': 1, 'picks': existing_list} + mock_client.get.return_value = {"count": 1, "picks": existing_list} result = await service.remove_player_from_list( season=12, team_id=1, player_id=999 # Not in list @@ -1242,16 +1266,16 @@ class TestDraftListService: Verifies DELETE /draftlist/team/{team_id} is called. """ existing_list = [ - create_draft_list_data(entry_id=i, rank=i, player_id=100+i) + create_draft_list_data(entry_id=i, rank=i, player_id=100 + i) for i in range(1, 6) ] - mock_client.get.return_value = {'count': 5, 'picks': existing_list} + mock_client.get.return_value = {"count": 5, "picks": existing_list} mock_client.delete.return_value = "Deleted 5 list values" result = await service.clear_list(season=12, team_id=1) assert result is True - mock_client.delete.assert_called_once_with('draftlist/team/1') + mock_client.delete.assert_called_once_with("draftlist/team/1") @pytest.mark.asyncio async def test_clear_list_already_empty(self, service, mock_client): @@ -1260,7 +1284,7 @@ class TestDraftListService: Verifies DELETE is not called when list is already empty. """ - mock_client.get.return_value = {'count': 0, 'picks': []} + mock_client.get.return_value = {"count": 0, "picks": []} result = await service.clear_list(season=12, team_id=1) @@ -1283,7 +1307,7 @@ class TestDraftListService: create_draft_list_data(entry_id=2, rank=2, player_id=102), create_draft_list_data(entry_id=3, rank=3, player_id=103), ] - mock_client.get.return_value = {'count': 3, 'picks': existing_list} + mock_client.get.return_value = {"count": 3, "picks": existing_list} mock_client.post.return_value = "Inserted 3 list values" # Reverse the order @@ -1296,10 +1320,10 @@ class TestDraftListService: post_call = mock_client.post.call_args payload = post_call[0][1] - entries_by_player = {e['player_id']: e for e in payload['draft_list']} - assert entries_by_player[103]['rank'] == 1 # Was 3 - assert entries_by_player[102]['rank'] == 2 # Unchanged - assert entries_by_player[101]['rank'] == 3 # Was 1 + entries_by_player = {e["player_id"]: e for e in payload["draft_list"]} + assert entries_by_player[103]["rank"] == 1 # Was 3 + assert entries_by_player[102]["rank"] == 2 # Unchanged + assert entries_by_player[101]["rank"] == 3 # Was 1 # ------------------------------------------------------------------------- # move_entry_up() tests @@ -1317,7 +1341,7 @@ class TestDraftListService: create_draft_list_data(entry_id=2, rank=2, player_id=102), create_draft_list_data(entry_id=3, rank=3, player_id=103), ] - mock_client.get.return_value = {'count': 3, 'picks': existing_list} + mock_client.get.return_value = {"count": 3, "picks": existing_list} mock_client.post.return_value = "Inserted 3 list values" result = await service.move_entry_up(season=12, team_id=1, player_id=102) @@ -1327,10 +1351,10 @@ class TestDraftListService: post_call = mock_client.post.call_args payload = post_call[0][1] - entries_by_player = {e['player_id']: e for e in payload['draft_list']} - assert entries_by_player[102]['rank'] == 1 # Moved up from 2 - assert entries_by_player[101]['rank'] == 2 # Moved down from 1 - assert entries_by_player[103]['rank'] == 3 # Unchanged + entries_by_player = {e["player_id"]: e for e in payload["draft_list"]} + assert entries_by_player[102]["rank"] == 1 # Moved up from 2 + assert entries_by_player[101]["rank"] == 2 # Moved down from 1 + assert entries_by_player[103]["rank"] == 3 # Unchanged @pytest.mark.asyncio async def test_move_entry_up_already_at_top(self, service, mock_client): @@ -1343,7 +1367,7 @@ class TestDraftListService: create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), ] - mock_client.get.return_value = {'count': 2, 'picks': existing_list} + mock_client.get.return_value = {"count": 2, "picks": existing_list} result = await service.move_entry_up(season=12, team_id=1, player_id=101) @@ -1366,7 +1390,7 @@ class TestDraftListService: create_draft_list_data(entry_id=2, rank=2, player_id=102), create_draft_list_data(entry_id=3, rank=3, player_id=103), ] - mock_client.get.return_value = {'count': 3, 'picks': existing_list} + mock_client.get.return_value = {"count": 3, "picks": existing_list} mock_client.post.return_value = "Inserted 3 list values" result = await service.move_entry_down(season=12, team_id=1, player_id=102) @@ -1376,10 +1400,10 @@ class TestDraftListService: post_call = mock_client.post.call_args payload = post_call[0][1] - entries_by_player = {e['player_id']: e for e in payload['draft_list']} - assert entries_by_player[102]['rank'] == 3 # Moved down from 2 - assert entries_by_player[103]['rank'] == 2 # Moved up from 3 - assert entries_by_player[101]['rank'] == 1 # Unchanged + entries_by_player = {e["player_id"]: e for e in payload["draft_list"]} + assert entries_by_player[102]["rank"] == 3 # Moved down from 2 + assert entries_by_player[103]["rank"] == 2 # Moved up from 3 + assert entries_by_player[101]["rank"] == 1 # Unchanged @pytest.mark.asyncio async def test_move_entry_down_already_at_bottom(self, service, mock_client): @@ -1392,7 +1416,7 @@ class TestDraftListService: create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), ] - mock_client.get.return_value = {'count': 2, 'picks': existing_list} + mock_client.get.return_value = {"count": 2, "picks": existing_list} result = await service.move_entry_down(season=12, team_id=1, player_id=102) @@ -1404,6 +1428,7 @@ class TestDraftListService: # DraftList Response Parsing Tests # ============================================================================= + class TestDraftListResponseParsing: """ Tests for DraftListService response parsing quirks. @@ -1433,11 +1458,11 @@ class TestDraftListResponseParsing: """ # Response uses 'picks' key response_data = { - 'count': 2, - 'picks': [ + "count": 2, + "picks": [ create_draft_list_data(entry_id=1, rank=1, player_id=101), create_draft_list_data(entry_id=2, rank=2, player_id=102), - ] + ], } mock_client.get.return_value = response_data @@ -1451,6 +1476,7 @@ class TestDraftListResponseParsing: # Global Service Instance Tests # ============================================================================= + class TestGlobalServiceInstances: """Tests for global service singleton instances.""" @@ -1458,36 +1484,32 @@ class TestGlobalServiceInstances: """Verify global draft_service instance is available.""" assert draft_service is not None assert isinstance(draft_service, DraftService) - assert draft_service.endpoint == 'draftdata' + assert draft_service.endpoint == "draftdata" def test_draft_pick_service_instance_exists(self): """Verify global draft_pick_service instance is available.""" assert draft_pick_service is not None assert isinstance(draft_pick_service, DraftPickService) - assert draft_pick_service.endpoint == 'draftpicks' + assert draft_pick_service.endpoint == "draftpicks" def test_draft_list_service_instance_exists(self): """Verify global draft_list_service instance is available.""" assert draft_list_service is not None assert isinstance(draft_list_service, DraftListService) - assert draft_list_service.endpoint == 'draftlist' + assert draft_list_service.endpoint == "draftlist" # ============================================================================= # Draft Model Tests # ============================================================================= + class TestDraftDataModel: """Tests for DraftData Pydantic model.""" def test_create_draft_data(self): """Test basic DraftData model creation.""" - data = DraftData( - id=1, - currentpick=25, - timer=True, - pick_minutes=2 - ) + data = DraftData(id=1, currentpick=25, timer=True, pick_minutes=2) assert data.currentpick == 25 assert data.timer is True assert data.pick_minutes == 2 @@ -1503,8 +1525,8 @@ class TestDraftDataModel: currentpick=1, timer=False, pick_minutes=2, - result_channel='123456789012345678', - ping_channel='987654321098765432' + result_channel="123456789012345678", + ping_channel="987654321098765432", ) assert data.result_channel == 123456789012345678 assert data.ping_channel == 987654321098765432 @@ -1552,9 +1574,7 @@ class TestDraftDataModel: assert not_paused.can_make_picks is True # Paused - cannot make picks - paused = DraftData( - id=1, currentpick=1, timer=True, paused=True, pick_minutes=2 - ) + paused = DraftData(id=1, currentpick=1, timer=True, paused=True, pick_minutes=2) assert paused.can_make_picks is False # Not paused, timer off - can still make picks (manual draft) @@ -1587,15 +1607,21 @@ class TestDraftDataModel: """Test is_pick_expired property.""" # Expired deadline expired = DraftData( - id=1, currentpick=1, timer=True, pick_minutes=2, - pick_deadline=datetime.now() - timedelta(minutes=5) + id=1, + currentpick=1, + timer=True, + pick_minutes=2, + pick_deadline=datetime.now(UTC) - timedelta(minutes=5), ) assert expired.is_pick_expired is True # Future deadline not_expired = DraftData( - id=1, currentpick=1, timer=True, pick_minutes=2, - pick_deadline=datetime.now() + timedelta(minutes=5) + id=1, + currentpick=1, + timer=True, + pick_minutes=2, + pick_deadline=datetime.now(UTC) + timedelta(minutes=5), ) assert not_expired.is_pick_expired is False @@ -1609,13 +1635,7 @@ class TestDraftPickModel: def test_create_draft_pick_minimal(self): """Test DraftPick with minimal required fields.""" - pick = DraftPick( - id=1, - season=12, - overall=42, - round=3, - origowner_id=1 - ) + pick = DraftPick(id=1, season=12, overall=42, round=3, origowner_id=1) assert pick.overall == 42 assert pick.round == 3 assert pick.player_id is None @@ -1629,7 +1649,7 @@ class TestDraftPickModel: round=3, origowner_id=1, owner_id=1, - player_id=999 + player_id=999, ) assert pick.player_id == 999 assert pick.is_selected is True @@ -1637,12 +1657,20 @@ class TestDraftPickModel: def test_is_traded_property(self): """Test is_traded property.""" traded = DraftPick( - id=1, season=12, overall=1, round=1, - origowner_id=1, owner_id=2 # Different owners + id=1, + season=12, + overall=1, + round=1, + origowner_id=1, + owner_id=2, # Different owners ) not_traded = DraftPick( - id=2, season=12, overall=2, round=1, - origowner_id=1, owner_id=1 # Same owner + id=2, + season=12, + overall=2, + round=1, + origowner_id=1, + owner_id=1, # Same owner ) assert traded.is_traded is True @@ -1651,12 +1679,10 @@ class TestDraftPickModel: def test_is_selected_property(self): """Test is_selected property.""" selected = DraftPick( - id=1, season=12, overall=1, round=1, - origowner_id=1, player_id=100 + id=1, season=12, overall=1, round=1, origowner_id=1, player_id=100 ) not_selected = DraftPick( - id=2, season=12, overall=2, round=1, - origowner_id=1, player_id=None + id=2, season=12, overall=2, round=1, origowner_id=1, player_id=None ) assert selected.is_selected is True @@ -1668,16 +1694,10 @@ class TestDraftListModel: def test_create_draft_list_entry(self): """Test DraftList model creation with nested objects.""" - team = Team(**create_team_data(1, 'WV')) - player = Player(**create_player_data(100, 'Target Player')) + team = Team(**create_team_data(1, "WV")) + player = Player(**create_player_data(100, "Target Player")) - entry = DraftList( - id=1, - season=12, - rank=1, - team=team, - player=player - ) + entry = DraftList(id=1, season=12, rank=1, team=team, player=player) assert entry.rank == 1 assert entry.team_id == 1 @@ -1685,7 +1705,7 @@ class TestDraftListModel: def test_team_id_property(self): """Test team_id property extracts ID from nested team.""" - team = Team(**create_team_data(42, 'TST')) + team = Team(**create_team_data(42, "TST")) player = Player(**create_player_data(100)) entry = DraftList(id=1, season=12, rank=1, team=team, player=player) @@ -1695,7 +1715,7 @@ class TestDraftListModel: def test_player_id_property(self): """Test player_id property extracts ID from nested player.""" team = Team(**create_team_data(1)) - player = Player(**create_player_data(999, 'Star Player')) + player = Player(**create_player_data(999, "Star Player")) entry = DraftList(id=1, season=12, rank=1, team=team, player=player) From d1a6b57ccd2d36a78f728012f0910cb0e516dfde Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Feb 2026 14:14:23 -0600 Subject: [PATCH 6/6] fix: scorebug stale data, win probability parsing, and read-failure tolerance (closes #39, #40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #40: ScorecardTracker cached data in memory at startup — background task never saw newly published scorecards. Fixed by reloading from disk on every read. #39: Win percentage defaulted to 50% when unavailable, showing a misleading 50/50 bar. Now defaults to None with "unavailable" message in embed. Parsing handles decimal (0.75), percentage string, and empty values. Also fixed orientation bug where win% was always shown as home team's even when the sheet reports the away team as the leader. Additionally: live scorebug tracker now distinguishes between "all games confirmed final" and "sheet read failures" — transient Google Sheets errors no longer hide the live scores channel. Co-Authored-By: Claude Opus 4.6 --- commands/gameplay/scorebug.py | 67 ++++------ commands/gameplay/scorecard_tracker.py | 28 ++-- services/scorebug_service.py | 68 ++++++++-- tasks/live_scorebug_tracker.py | 91 ++++++++----- tests/test_scorebug_bugs.py | 176 +++++++++++++++++++++++++ utils/scorebug_helpers.py | 167 ++++++++++++----------- 6 files changed, 423 insertions(+), 174 deletions(-) create mode 100644 tests/test_scorebug_bugs.py diff --git a/commands/gameplay/scorebug.py b/commands/gameplay/scorebug.py index 24b996a..dee4780 100644 --- a/commands/gameplay/scorebug.py +++ b/commands/gameplay/scorebug.py @@ -3,6 +3,7 @@ Scorebug Commands Implements commands for publishing and displaying live game scorebugs from Google Sheets scorecards. """ + import discord from discord.ext import commands from discord import app_commands @@ -23,25 +24,21 @@ class ScorebugCommands(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.ScorebugCommands') + self.logger = get_contextual_logger(f"{__name__}.ScorebugCommands") self.scorebug_service = ScorebugService() self.scorecard_tracker = ScorecardTracker() self.logger.info("ScorebugCommands cog initialized") @app_commands.command( name="publish-scorecard", - description="Publish a Google Sheets scorecard to this channel for live tracking" + description="Publish a Google Sheets scorecard to this channel for live tracking", ) @app_commands.describe( url="Full URL to the Google Sheets scorecard or just the sheet key" ) @league_only() @logged_command("/publish-scorecard") - async def publish_scorecard( - self, - interaction: discord.Interaction, - url: str - ): + async def publish_scorecard(self, interaction: discord.Interaction, url: str): """ Link a Google Sheets scorecard to the current channel for live scorebug tracking. @@ -61,14 +58,16 @@ class ScorebugCommands(commands.Cog): # Verify it has a Scorebug tab try: - scorebug_data = await self.scorebug_service.read_scorebug_data(url, full_length=False) + scorebug_data = await self.scorebug_service.read_scorebug_data( + url, full_length=False + ) except SheetsException: embed = EmbedTemplate.error( title="Invalid Scorecard", description=( "This doesn't appear to be a valid scorecard.\n\n" "Make sure the sheet has a 'Scorebug' tab and is properly set up." - ) + ), ) await interaction.edit_original_response(content=None, embed=embed) return @@ -88,22 +87,22 @@ class ScorebugCommands(commands.Cog): # Store the scorecard in the tracker self.scorecard_tracker.publish_scorecard( - text_channel_id=interaction.channel_id, # type: ignore + text_channel_id=interaction.channel_id, # type: ignore sheet_url=url, - publisher_id=interaction.user.id + publisher_id=interaction.user.id, ) # Create success embed embed = EmbedTemplate.success( title="Scorecard Published", description=( - f"Your scorecard has been published to {interaction.channel.mention}\n\n" # type: ignore + f"Your scorecard has been published to {interaction.channel.mention}\n\n" # type: ignore f"**Sheet:** {scorecard.title}\n" f"**Status:** Live tracking enabled\n" f"**Scorecard:** {scorecard_link}\n\n" f"Anyone can now run `/scorebug` in this channel to see the current score.\n" f"The scorebug will also update in the live scores channel every 3 minutes." - ) + ), ) embed.add_field( @@ -112,7 +111,7 @@ class ScorebugCommands(commands.Cog): "`/scorebug` - Display full scorebug with details\n" "`/scorebug full_length:False` - Display compact scorebug" ), - inline=False + inline=False, ) await interaction.edit_original_response(content=None, embed=embed) @@ -121,13 +120,14 @@ class ScorebugCommands(commands.Cog): embed = EmbedTemplate.error( title="Cannot Access Scorecard", description=( - f"❌ {str(e)}\n\n" + f"{str(e)}\n\n" + f"**You provided:** `{url}`\n\n" f"**Common issues:**\n" f"• Sheet is not publicly accessible\n" f"• Invalid sheet URL or key\n" f"• Sheet doesn't exist\n\n" f"Make sure your sheet is shared with 'Anyone with the link can view'." - ) + ), ) await interaction.edit_original_response(content=None, embed=embed) @@ -138,23 +138,18 @@ class ScorebugCommands(commands.Cog): description=( "❌ An unexpected error occurred while publishing the scorecard.\n\n" "Please try again or contact support if the issue persists." - ) + ), ) await interaction.edit_original_response(content=None, embed=embed) @app_commands.command( - name="scorebug", - description="Display the scorebug for the game in this channel" - ) - @app_commands.describe( - full_length="Include full game details (defaults to True)" + name="scorebug", description="Display the scorebug for the game in this channel" ) + @app_commands.describe(full_length="Include full game details (defaults to True)") @league_only() @logged_command("/scorebug") async def scorebug( - self, - interaction: discord.Interaction, - full_length: bool = True + self, interaction: discord.Interaction, full_length: bool = True ): """ Display the current scorebug from the scorecard published in this channel. @@ -162,7 +157,7 @@ class ScorebugCommands(commands.Cog): await interaction.response.defer(ephemeral=True) # Check if a scorecard is published in this channel - sheet_url = self.scorecard_tracker.get_scorecard(interaction.channel_id) # type: ignore + sheet_url = self.scorecard_tracker.get_scorecard(interaction.channel_id) # type: ignore if not sheet_url: embed = EmbedTemplate.error( @@ -170,20 +165,17 @@ class ScorebugCommands(commands.Cog): description=( "❌ No scorecard has been published in this channel.\n\n" "Use `/publish-scorecard ` to publish a scorecard first." - ) + ), ) await interaction.followup.send(embed=embed, ephemeral=True) return try: # Read scorebug data - await interaction.edit_original_response( - content="📊 Reading scorebug..." - ) + await interaction.edit_original_response(content="📊 Reading scorebug...") scorebug_data = await self.scorebug_service.read_scorebug_data( - sheet_url, - full_length=full_length + sheet_url, full_length=full_length ) # Get team data @@ -196,16 +188,13 @@ class ScorebugCommands(commands.Cog): # Create scorebug embed using shared utility embed = create_scorebug_embed( - scorebug_data, - away_team, - home_team, - full_length + scorebug_data, away_team, home_team, full_length ) await interaction.edit_original_response(content=None, embed=embed) # Update timestamp in tracker - self.scorecard_tracker.update_timestamp(interaction.channel_id) # type: ignore + self.scorecard_tracker.update_timestamp(interaction.channel_id) # type: ignore except SheetsException as e: embed = EmbedTemplate.error( @@ -213,7 +202,7 @@ class ScorebugCommands(commands.Cog): description=( f"❌ {str(e)}\n\n" f"The scorecard may have been deleted or the sheet structure changed." - ) + ), ) await interaction.edit_original_response(content=None, embed=embed) @@ -224,7 +213,7 @@ class ScorebugCommands(commands.Cog): description=( "❌ An error occurred while reading the scorebug.\n\n" "Please try again or republish the scorecard." - ) + ), ) await interaction.edit_original_response(content=None, embed=embed) diff --git a/commands/gameplay/scorecard_tracker.py b/commands/gameplay/scorecard_tracker.py index 533a977..8b2a674 100644 --- a/commands/gameplay/scorecard_tracker.py +++ b/commands/gameplay/scorecard_tracker.py @@ -3,13 +3,14 @@ Scorecard Tracker Provides persistent tracking of published scorecards per Discord text channel using JSON file storage. """ + import json import logging from datetime import datetime, UTC from pathlib import Path from typing import Dict, List, Optional, Tuple -logger = logging.getLogger(f'{__name__}.ScorecardTracker') +logger = logging.getLogger(f"{__name__}.ScorecardTracker") class ScorecardTracker: @@ -39,9 +40,11 @@ class ScorecardTracker: """Load scorecard data from JSON file.""" try: if self.data_file.exists(): - with open(self.data_file, 'r') as f: + with open(self.data_file, "r") as f: self._data = json.load(f) - logger.debug(f"Loaded {len(self._data.get('scorecards', {}))} tracked scorecards") + logger.debug( + f"Loaded {len(self._data.get('scorecards', {}))} tracked scorecards" + ) else: self._data = {"scorecards": {}} logger.info("No existing scorecard data found, starting fresh") @@ -52,17 +55,14 @@ class ScorecardTracker: def save_data(self) -> None: """Save scorecard data to JSON file.""" try: - with open(self.data_file, 'w') as f: + with open(self.data_file, "w") as f: json.dump(self._data, f, indent=2, default=str) logger.debug("Scorecard data saved successfully") except Exception as e: logger.error(f"Failed to save scorecard data: {e}") def publish_scorecard( - self, - text_channel_id: int, - sheet_url: str, - publisher_id: int + self, text_channel_id: int, sheet_url: str, publisher_id: int ) -> None: """ Link a scorecard to a text channel. @@ -77,7 +77,7 @@ class ScorecardTracker: "sheet_url": sheet_url, "published_at": datetime.now(UTC).isoformat(), "last_updated": datetime.now(UTC).isoformat(), - "publisher_id": str(publisher_id) + "publisher_id": str(publisher_id), } self.save_data() logger.info(f"Published scorecard to channel {text_channel_id}: {sheet_url}") @@ -113,6 +113,7 @@ class ScorecardTracker: Returns: Sheet URL if published, None otherwise """ + self.load_data() scorecards = self._data.get("scorecards", {}) scorecard_data = scorecards.get(str(text_channel_id)) return scorecard_data["sheet_url"] if scorecard_data else None @@ -124,6 +125,7 @@ class ScorecardTracker: Returns: List of (text_channel_id, sheet_url) tuples """ + self.load_data() scorecards = self._data.get("scorecards", {}) return [ (int(channel_id), data["sheet_url"]) @@ -163,13 +165,17 @@ class ScorecardTracker: if channel_id not in valid_channel_ids: stale_entries.append(channel_id_str) except (ValueError, TypeError): - logger.warning(f"Invalid channel ID in scorecard data: {channel_id_str}") + logger.warning( + f"Invalid channel ID in scorecard data: {channel_id_str}" + ) stale_entries.append(channel_id_str) # Remove stale entries for channel_id_str in stale_entries: del scorecards[channel_id_str] - logger.info(f"Removed stale scorecard entry for channel ID: {channel_id_str}") + logger.info( + f"Removed stale scorecard entry for channel ID: {channel_id_str}" + ) if stale_entries: self.save_data() diff --git a/services/scorebug_service.py b/services/scorebug_service.py index f19494f..3f8adae 100644 --- a/services/scorebug_service.py +++ b/services/scorebug_service.py @@ -26,7 +26,7 @@ class ScorebugData: self.inning = data.get("inning", 1) self.is_final = data.get("is_final", False) self.outs = data.get("outs", 0) - self.win_percentage = data.get("win_percentage", 50.0) + self.win_percentage = data.get("win_percentage") # Current matchup information self.pitcher_name = data.get("pitcher_name", "") @@ -315,22 +315,70 @@ class ScorebugService(SheetsService): in_hole_url = matchups[3][1] if len(matchups[3]) > 1 else "" self.logger.debug(f" In Hole: {in_hole_name}") - # Parse win percentage from all_data[6][2] (Sheet D8 - row 8, column D) - self.logger.debug(f"📈 Parsing win percentage from D8 (all_data[6][2]):") + # Parse win percentage from C8 (team abbrev) and D8 (percentage) + # C8 = all_data[6][1] = winning team abbreviation + # D8 = all_data[6][2] = win probability percentage + # The sheet outputs the LEADING team's win%, so we need to + # normalize to home team's win% for the progress bar. + self.logger.debug( + f"📈 Parsing win percentage from C8:D8 (all_data[6][1:3]):" + ) try: + win_pct_team_raw = ( + all_data[6][1] + if len(all_data) > 6 and len(all_data[6]) > 1 + else None + ) win_pct_raw = ( all_data[6][2] if len(all_data) > 6 and len(all_data[6]) > 2 - else "50%" + else None ) + self.logger.debug(f" Raw win percentage team: '{win_pct_team_raw}'") self.logger.debug(f" Raw win percentage value: '{win_pct_raw}'") - # Remove % sign if present and convert to float - win_pct_str = str(win_pct_raw).replace("%", "").strip() - win_percentage = float(win_pct_str) if win_pct_str else 50.0 - self.logger.debug(f" ✅ Parsed win percentage: {win_percentage}%") + + if win_pct_raw is None or str(win_pct_raw).strip() == "": + self.logger.info( + f" Win percentage unavailable (raw value: '{win_pct_raw}')" + ) + win_percentage = None + else: + # Remove % sign if present and convert to float + win_pct_str = str(win_pct_raw).replace("%", "").strip() + win_percentage = float(win_pct_str) + + # Handle 0.0-1.0 range (pygsheets may return decimal like 0.75) + if 0.0 <= win_percentage <= 1.0: + win_percentage = win_percentage * 100 + + # The sheet gives the LEADING team's win%. + # Progress bar expects HOME team's win%. + # Compare C8 abbreviation to home team abbreviation to orient correctly. + home_abbrev_raw = ( + game_state[4][1] + if len(game_state) > 4 and len(game_state[4]) > 1 + else "" + ) + win_pct_team = ( + str(win_pct_team_raw).strip() if win_pct_team_raw else "" + ) + + if win_pct_team and win_pct_team != home_abbrev_raw: + # The percentage belongs to the away team, flip for home perspective + self.logger.debug( + f" Win% team '{win_pct_team}' is away (home is '{home_abbrev_raw}'), " + f"flipping {win_percentage}% -> {100 - win_percentage}%" + ) + win_percentage = 100 - win_percentage + + self.logger.debug( + f" ✅ Parsed home win percentage: {win_percentage}%" + ) except (ValueError, IndexError, AttributeError) as e: - self.logger.warning(f" ⚠️ Failed to parse win percentage: {e}") - win_percentage = 50.0 + self.logger.info( + f" Win percentage could not be parsed (raw value: '{win_pct_raw}'): {e}" + ) + win_percentage = None self.logger.debug(f"📊 Final parsed values:") self.logger.debug(f" Away team {away_team_id}: {away_score}") diff --git a/tasks/live_scorebug_tracker.py b/tasks/live_scorebug_tracker.py index 5f76269..9013ac2 100644 --- a/tasks/live_scorebug_tracker.py +++ b/tasks/live_scorebug_tracker.py @@ -3,6 +3,7 @@ Live Scorebug Tracker Background task that monitors published scorecards and updates live score displays. """ + import asyncio from typing import List import discord @@ -39,7 +40,7 @@ class LiveScorebugTracker: bot: Discord bot instance """ self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.LiveScorebugTracker') + self.logger = get_contextual_logger(f"{__name__}.LiveScorebugTracker") self.scorebug_service = ScorebugService() self.scorecard_tracker = ScorecardTracker() self.voice_tracker = VoiceChannelTracker() @@ -83,10 +84,14 @@ class LiveScorebugTracker: return # Get live scores channel - live_scores_channel = discord.utils.get(guild.text_channels, name='live-sba-scores') + live_scores_channel = discord.utils.get( + guild.text_channels, name="live-sba-scores" + ) if not live_scores_channel: - self.logger.warning("live-sba-scores channel not found, skipping channel update") + self.logger.warning( + "live-sba-scores channel not found, skipping channel update" + ) # Don't return - still update voice channels else: # Get all published scorecards @@ -96,76 +101,84 @@ class LiveScorebugTracker: # No active scorebugs - clear the channel and hide it await self._clear_live_scores_channel(live_scores_channel) await set_channel_visibility( - live_scores_channel, - visible=False, - reason="No active games" + live_scores_channel, visible=False, reason="No active games" ) return # Read all scorebugs and create embeds active_scorebugs = [] + read_failures = 0 + confirmed_final = 0 for text_channel_id, sheet_url in all_scorecards: try: scorebug_data = await self.scorebug_service.read_scorebug_data( - sheet_url, - full_length=False # Compact view for live channel + sheet_url, full_length=False # Compact view for live channel ) # Only include active (non-final) games if scorebug_data.is_active: # Get team data - away_team = await team_service.get_team(scorebug_data.away_team_id) - home_team = await team_service.get_team(scorebug_data.home_team_id) + away_team = await team_service.get_team( + scorebug_data.away_team_id + ) + home_team = await team_service.get_team( + scorebug_data.home_team_id + ) if away_team is None or home_team is None: - raise ValueError(f'Error looking up teams in scorecard; IDs provided: {scorebug_data.away_team_id} & {scorebug_data.home_team_id}') + raise ValueError( + f"Error looking up teams in scorecard; IDs provided: {scorebug_data.away_team_id} & {scorebug_data.home_team_id}" + ) # Create compact embed using shared utility embed = create_scorebug_embed( scorebug_data, away_team, home_team, - full_length=False # Compact view for live channel + full_length=False, # Compact view for live channel ) active_scorebugs.append(embed) # Update associated voice channel if it exists await self._update_voice_channel_description( - text_channel_id, - scorebug_data, - away_team, - home_team + text_channel_id, scorebug_data, away_team, home_team ) + else: + confirmed_final += 1 await asyncio.sleep(1) # Rate limit between reads except SheetsException as e: + read_failures += 1 self.logger.warning(f"Could not read scorecard {sheet_url}: {e}") except Exception as e: + read_failures += 1 self.logger.error(f"Error processing scorecard {sheet_url}: {e}") # Update live scores channel if active_scorebugs: await set_channel_visibility( - live_scores_channel, - visible=True, - reason="Active games in progress" + live_scores_channel, visible=True, reason="Active games in progress" + ) + await self._post_scorebugs_to_channel( + live_scores_channel, active_scorebugs + ) + elif read_failures > 0 and confirmed_final < len(all_scorecards): + # Some reads failed — don't hide the channel, preserve last state + self.logger.warning( + f"Skipping channel hide: {read_failures} scorecard read(s) failed, " + f"only {confirmed_final}/{len(all_scorecards)} confirmed final" ) - await self._post_scorebugs_to_channel(live_scores_channel, active_scorebugs) else: - # All games finished - clear the channel and hide it + # All games confirmed final — safe to clear and hide await self._clear_live_scores_channel(live_scores_channel) await set_channel_visibility( - live_scores_channel, - visible=False, - reason="No active games" + live_scores_channel, visible=False, reason="No active games" ) async def _post_scorebugs_to_channel( - self, - channel: discord.TextChannel, - embeds: List[discord.Embed] + self, channel: discord.TextChannel, embeds: List[discord.Embed] ): """ Post scorebugs to the live scores channel. @@ -185,7 +198,7 @@ class LiveScorebugTracker: else: # Split into multiple messages if more than 10 embeds for i in range(0, len(embeds), 10): - batch = embeds[i:i+10] + batch = embeds[i : i + 10] await channel.send(embeds=batch) self.logger.info(f"Posted {len(embeds)} scorebugs to live-sba-scores") @@ -219,7 +232,7 @@ class LiveScorebugTracker: text_channel_id: int, scorebug_data: ScorebugData, away_team: Team, - home_team: Team + home_team: Team, ): """ Update voice channel description with live score. @@ -232,10 +245,14 @@ class LiveScorebugTracker: """ try: # Check if there's an associated voice channel - voice_channel_id = self.voice_tracker.get_voice_channel_for_text_channel(text_channel_id) + voice_channel_id = self.voice_tracker.get_voice_channel_for_text_channel( + text_channel_id + ) if not voice_channel_id: - self.logger.debug(f'No voice channel associated with text channel ID {text_channel_id} (may have been cleaned up)') + self.logger.debug( + f"No voice channel associated with text channel ID {text_channel_id} (may have been cleaned up)" + ) return # No associated voice channel # Get the voice channel @@ -248,7 +265,9 @@ class LiveScorebugTracker: voice_channel = guild.get_channel(voice_channel_id) if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel): - self.logger.debug(f"Voice channel {voice_channel_id} not found or wrong type") + self.logger.debug( + f"Voice channel {voice_channel_id} not found or wrong type" + ) return # Format description: "BOS 4 @ 3 NYY" or "BOS 5 @ 3 NYY - FINAL" @@ -263,10 +282,14 @@ class LiveScorebugTracker: # Update voice channel description (topic) await voice_channel.edit(status=description) - self.logger.debug(f"Updated voice channel {voice_channel.name} description to: {description}") + self.logger.debug( + f"Updated voice channel {voice_channel.name} description to: {description}" + ) except discord.Forbidden: - self.logger.warning(f"Missing permissions to update voice channel {voice_channel_id}") + self.logger.warning( + f"Missing permissions to update voice channel {voice_channel_id}" + ) except Exception as e: self.logger.error(f"Error updating voice channel description: {e}") diff --git a/tests/test_scorebug_bugs.py b/tests/test_scorebug_bugs.py new file mode 100644 index 0000000..c36023b --- /dev/null +++ b/tests/test_scorebug_bugs.py @@ -0,0 +1,176 @@ +""" +Tests for scorebug bug fixes (#39 and #40). + +#40: ScorecardTracker reads stale in-memory data — fix ensures get_scorecard() + and get_all_scorecards() reload from disk before returning data. + +#39: Win percentage stuck at 50% — fix makes parsing robust for decimal (0.75), + percentage string ("75%"), plain number ("75"), empty, and None values. + When parsing fails, win_percentage is None instead of a misleading 50.0. +""" + +import json +import tempfile +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from commands.gameplay.scorecard_tracker import ScorecardTracker +from services.scorebug_service import ScorebugData +from utils.scorebug_helpers import create_scorebug_embed, create_team_progress_bar + + +class TestScorecardTrackerFreshReads: + """Tests that ScorecardTracker reads fresh data from disk (fix for #40).""" + + def test_get_all_scorecards_reads_fresh_data(self, tmp_path): + """get_all_scorecards() should pick up scorecards written by another process. + + Simulates the background task having a stale tracker instance while + the /publish-scorecard command writes new data to the JSON file. + """ + data_file = tmp_path / "scorecards.json" + data_file.write_text(json.dumps({"scorecards": {}})) + + tracker = ScorecardTracker(data_file=str(data_file)) + assert tracker.get_all_scorecards() == [] + + # Another process writes a scorecard to the same file + new_data = { + "scorecards": { + "111": { + "text_channel_id": "111", + "sheet_url": "https://docs.google.com/spreadsheets/d/abc123", + "published_at": "2026-01-01T00:00:00", + "last_updated": "2026-01-01T00:00:00", + "publisher_id": "999", + } + } + } + data_file.write_text(json.dumps(new_data)) + + # Should see the new scorecard without restart + result = tracker.get_all_scorecards() + assert len(result) == 1 + assert result[0] == (111, "https://docs.google.com/spreadsheets/d/abc123") + + def test_get_scorecard_reads_fresh_data(self, tmp_path): + """get_scorecard() should pick up a scorecard written by another process.""" + data_file = tmp_path / "scorecards.json" + data_file.write_text(json.dumps({"scorecards": {}})) + + tracker = ScorecardTracker(data_file=str(data_file)) + assert tracker.get_scorecard(222) is None + + # Another process writes a scorecard + new_data = { + "scorecards": { + "222": { + "text_channel_id": "222", + "sheet_url": "https://docs.google.com/spreadsheets/d/xyz789", + "published_at": "2026-01-01T00:00:00", + "last_updated": "2026-01-01T00:00:00", + "publisher_id": "999", + } + } + } + data_file.write_text(json.dumps(new_data)) + + # Should see the new scorecard + assert ( + tracker.get_scorecard(222) + == "https://docs.google.com/spreadsheets/d/xyz789" + ) + + +class TestWinPercentageParsing: + """Tests for robust win percentage parsing in ScorebugData (fix for #39).""" + + def test_percentage_string(self): + """'75%' string should parse to 75.0.""" + data = ScorebugData({"win_percentage": 75.0}) + assert data.win_percentage == 75.0 + + def test_none_default(self): + """Missing win_percentage key should default to None.""" + data = ScorebugData({}) + assert data.win_percentage is None + + def test_explicit_none(self): + """Explicit None should stay None.""" + data = ScorebugData({"win_percentage": None}) + assert data.win_percentage is None + + def test_zero_is_valid(self): + """0.0 win percentage is a valid value (team certain to lose).""" + data = ScorebugData({"win_percentage": 0.0}) + assert data.win_percentage == 0.0 + + +class TestWinPercentageEmbed: + """Tests for embed creation with win_percentage=None (fix for #39 Part B).""" + + def _make_scorebug_data(self, win_percentage): + """Create minimal ScorebugData for embed testing.""" + return ScorebugData( + { + "away_team_id": 1, + "home_team_id": 2, + "header": "Test Game", + "away_score": 10, + "home_score": 2, + "which_half": "Top", + "inning": 5, + "is_final": False, + "outs": 1, + "win_percentage": win_percentage, + "pitcher_name": "", + "batter_name": "", + "runners": [["", ""], ["", ""], ["", ""], ["", ""]], + "summary": [], + } + ) + + def _make_team(self, abbrev, color_int=0x3498DB): + """Create a mock team object.""" + team = MagicMock() + team.abbrev = abbrev + team.get_color_int.return_value = color_int + return team + + def test_embed_with_none_win_percentage_shows_unavailable(self): + """When win_percentage is None, embed should show unavailable message.""" + data = self._make_scorebug_data(win_percentage=None) + away = self._make_team("POR") + home = self._make_team("WV") + + embed = create_scorebug_embed(data, away, home, full_length=False) + + # Find the Win Probability field + win_prob_field = next(f for f in embed.fields if f.name == "Win Probability") + assert "unavailable" in win_prob_field.value.lower() + + def test_embed_with_valid_win_percentage_shows_bar(self): + """When win_percentage is valid, embed should show the progress bar.""" + data = self._make_scorebug_data(win_percentage=75.0) + away = self._make_team("POR") + home = self._make_team("WV") + + embed = create_scorebug_embed(data, away, home, full_length=False) + + win_prob_field = next(f for f in embed.fields if f.name == "Win Probability") + assert "75.0%" in win_prob_field.value + assert "unavailable" not in win_prob_field.value.lower() + + def test_embed_with_50_percent_shows_even_bar(self): + """50% win probability should show the even/balanced bar.""" + data = self._make_scorebug_data(win_percentage=50.0) + away = self._make_team("POR") + home = self._make_team("WV") + + embed = create_scorebug_embed(data, away, home, full_length=False) + + win_prob_field = next(f for f in embed.fields if f.name == "Win Probability") + assert "50.0%" in win_prob_field.value + assert "=" in win_prob_field.value diff --git a/utils/scorebug_helpers.py b/utils/scorebug_helpers.py index 325557c..14e763b 100644 --- a/utils/scorebug_helpers.py +++ b/utils/scorebug_helpers.py @@ -3,16 +3,14 @@ Scorebug Display Helpers Utility functions for formatting and displaying scorebug information. """ + import discord from views.embeds import EmbedColors def create_scorebug_embed( - scorebug_data, - away_team, - home_team, - full_length: bool = True + scorebug_data, away_team, home_team, full_length: bool = True ) -> discord.Embed: """ Create a rich embed from scorebug data. @@ -29,66 +27,86 @@ def create_scorebug_embed( # Determine embed color based on win probability (not score!) # This creates a fun twist where the favored team's color shows, # even if they're currently losing - if scorebug_data.win_percentage > 50 and home_team: + if ( + scorebug_data.win_percentage is not None + and scorebug_data.win_percentage > 50 + and home_team + ): embed_color = home_team.get_color_int() # Home team favored - elif scorebug_data.win_percentage < 50 and away_team: + elif ( + scorebug_data.win_percentage is not None + and scorebug_data.win_percentage < 50 + and away_team + ): embed_color = away_team.get_color_int() # Away team favored else: - embed_color = EmbedColors.INFO # Even game (50/50) + embed_color = EmbedColors.INFO # Even game (50/50) or unavailable # Create embed with header as title - embed = discord.Embed( - title=scorebug_data.header, - color=embed_color - ) + embed = discord.Embed(title=scorebug_data.header, color=embed_color) # Get team abbreviations for use throughout away_abbrev = away_team.abbrev if away_team else "AWAY" home_abbrev = home_team.abbrev if home_team else "HOME" # Create ASCII scorebug with bases visualization - occupied = '●' - unoccupied = '○' + occupied = "●" + unoccupied = "○" # runners[0]=Catcher, [1]=On First, [2]=On Second, [3]=On Third - first_base = unoccupied if not scorebug_data.runners[1] or not scorebug_data.runners[1][0] else occupied - second_base = unoccupied if not scorebug_data.runners[2] or not scorebug_data.runners[2][0] else occupied - third_base = unoccupied if not scorebug_data.runners[3] or not scorebug_data.runners[3][0] else occupied - half = '▲' if scorebug_data.which_half == 'Top' else '▼' + first_base = ( + unoccupied + if not scorebug_data.runners[1] or not scorebug_data.runners[1][0] + else occupied + ) + second_base = ( + unoccupied + if not scorebug_data.runners[2] or not scorebug_data.runners[2][0] + else occupied + ) + third_base = ( + unoccupied + if not scorebug_data.runners[3] or not scorebug_data.runners[3][0] + else occupied + ) + half = "▲" if scorebug_data.which_half == "Top" else "▼" if not scorebug_data.is_final: - inning = f'{half} {scorebug_data.inning}' + inning = f"{half} {scorebug_data.inning}" outs = f'{scorebug_data.outs} Out{"s" if scorebug_data.outs != 1 else ""}' else: # Final inning display - final_inning = scorebug_data.inning if scorebug_data.which_half == "Bot" else scorebug_data.inning - 1 - inning = f'F/{final_inning}' - outs = '' + final_inning = ( + scorebug_data.inning + if scorebug_data.which_half == "Bot" + else scorebug_data.inning - 1 + ) + inning = f"F/{final_inning}" + outs = "" game_state_text = ( - f'```\n' - f'{away_abbrev: ^4}{scorebug_data.away_score: ^3} {second_base}{inning: >10}\n' - f'{home_abbrev: ^4}{scorebug_data.home_score: ^3} {third_base} {first_base}{outs: >8}\n' - f'```' + f"```\n" + f"{away_abbrev: ^4}{scorebug_data.away_score: ^3} {second_base}{inning: >10}\n" + f"{home_abbrev: ^4}{scorebug_data.home_score: ^3} {third_base} {first_base}{outs: >8}\n" + f"```" ) - # Add win probability bar + # Add win probability bar or unavailable message + if scorebug_data.win_percentage is not None: + win_prob_value = create_team_progress_bar( + scorebug_data.win_percentage, away_abbrev, home_abbrev + ) + else: + win_prob_value = "-# Win probability unavailable" + embed.add_field( - name='Win Probability', - value=create_team_progress_bar( - scorebug_data.win_percentage, - away_abbrev, - home_abbrev - ), - inline=False + name="Win Probability", + value=win_prob_value, + inline=False, ) # Add game state - embed.add_field( - name='Game State', - value=game_state_text, - inline=False - ) + embed.add_field(name="Game State", value=game_state_text, inline=False) # If not full_length, return compact version if not full_length: @@ -97,70 +115,59 @@ def create_scorebug_embed( # Full length - add pitcher and batter info if scorebug_data.pitcher_name: embed.add_field( - name='Pitcher', - value=f'[{scorebug_data.pitcher_name}]({scorebug_data.pitcher_url})\n{scorebug_data.pitcher_stats}', - inline=True + name="Pitcher", + value=f"[{scorebug_data.pitcher_name}]({scorebug_data.pitcher_url})\n{scorebug_data.pitcher_stats}", + inline=True, ) if scorebug_data.batter_name: embed.add_field( - name='Batter', - value=f'[{scorebug_data.batter_name}]({scorebug_data.batter_url})\n{scorebug_data.batter_stats}', - inline=True + name="Batter", + value=f"[{scorebug_data.batter_name}]({scorebug_data.batter_url})\n{scorebug_data.batter_stats}", + inline=True, ) # Add baserunners if present - on_first = scorebug_data.runners[1] if scorebug_data.runners[1] else '' - on_second = scorebug_data.runners[2] if scorebug_data.runners[2] else '' - on_third = scorebug_data.runners[3] if scorebug_data.runners[3] else '' + on_first = scorebug_data.runners[1] if scorebug_data.runners[1] else "" + on_second = scorebug_data.runners[2] if scorebug_data.runners[2] else "" + on_third = scorebug_data.runners[3] if scorebug_data.runners[3] else "" have_baserunners = len(on_first[0]) + len(on_second[0]) + len(on_third[0]) > 0 if have_baserunners > 0: - br_string = '' + br_string = "" if len(on_first) > 0: - br_string += f'On First: [{on_first[0]}]({on_first[1]})\n' + br_string += f"On First: [{on_first[0]}]({on_first[1]})\n" if len(on_second) > 0: - br_string += f'On Second: [{on_second[0]}]({on_second[1]})\n' + br_string += f"On Second: [{on_second[0]}]({on_second[1]})\n" if len(on_third) > 0: - br_string += f'On Third: [{on_third[0]}]({on_third[1]})\n' + br_string += f"On Third: [{on_third[0]}]({on_third[1]})\n" - embed.add_field(name=' ', value=' ', inline=False) # Spacer - embed.add_field( - name='Baserunners', - value=br_string, - inline=True - ) + embed.add_field(name=" ", value=" ", inline=False) # Spacer + embed.add_field(name="Baserunners", value=br_string, inline=True) # Add catcher if scorebug_data.runners[0] and scorebug_data.runners[0][0]: embed.add_field( - name='Catcher', - value=f'[{scorebug_data.runners[0][0]}]({scorebug_data.runners[0][1]})', - inline=True + name="Catcher", + value=f"[{scorebug_data.runners[0][0]}]({scorebug_data.runners[0][1]})", + inline=True, ) # Add inning summary if not final if not scorebug_data.is_final and scorebug_data.summary: - i_string = '' + i_string = "" for line in scorebug_data.summary: if line and len(line) >= 2 and line[0]: - i_string += f'- Play {line[0]}: {line[1]}\n' + i_string += f"- Play {line[0]}: {line[1]}\n" if i_string and "IFERROR" not in i_string: - embed.add_field( - name='Inning Summary', - value=i_string, - inline=False - ) + embed.add_field(name="Inning Summary", value=i_string, inline=False) return embed def create_team_progress_bar( - win_percentage: float, - away_abbrev: str, - home_abbrev: str, - length: int = 10 + win_percentage: float, away_abbrev: str, home_abbrev: str, length: int = 10 ) -> str: """ Create a proportional progress bar showing each team's win probability. @@ -186,23 +193,23 @@ def create_team_progress_bar( if win_percentage > 50: # Home team (right side) is winning - away_char = '░' # Light blocks for losing team - home_char = '▓' # Dark blocks for winning team + away_char = "░" # Light blocks for losing team + home_char = "▓" # Dark blocks for winning team bar = away_char * away_blocks + home_char * home_blocks # Arrow extends from right side, percentage on right - return f'{away_abbrev} {bar}► {home_abbrev} {win_percentage:.1f}%' + return f"{away_abbrev} {bar}► {home_abbrev} {win_percentage:.1f}%" elif win_percentage < 50: # Away team (left side) is winning - away_char = '▓' # Dark blocks for winning team - home_char = '░' # Light blocks for losing team + away_char = "▓" # Dark blocks for winning team + home_char = "░" # Light blocks for losing team bar = away_char * away_blocks + home_char * home_blocks # Arrow extends from left side, percentage on left (showing away team's win %) away_win_pct = 100 - win_percentage - return f'{away_win_pct:.1f}% {away_abbrev} ◄{bar} {home_abbrev}' + return f"{away_win_pct:.1f}% {away_abbrev} ◄{bar} {home_abbrev}" else: # Even game (50/50) - away_char = '▓' - home_char = '▓' + away_char = "▓" + home_char = "▓" bar = away_char * away_blocks + home_char * home_blocks # Arrows on both sides for balanced display, percentage on both sides - return f'{win_percentage:.1f}% {away_abbrev} ={bar}= {home_abbrev} {win_percentage:.1f}%' + return f"{win_percentage:.1f}% {away_abbrev} ={bar}= {home_abbrev} {win_percentage:.1f}%"