fix: use explicit America/Chicago timezone for freeze/thaw scheduling
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m23s

The production container has ambiguous timezone config — /etc/localtime
points to Etc/UTC but date reports CST. The transaction freeze/thaw task
used datetime.now() (naive, relying on OS timezone), causing scheduling
to fire at unpredictable wall-clock times.

- Add utils/timezone.py with centralized Chicago timezone helpers
- Fix tasks/transaction_freeze.py to use now_chicago() for scheduling
- Fix utils/logging.py timestamp to use proper UTC-aware datetime
- Add 14 timezone utility tests
- Update freeze task tests to mock now_chicago instead of datetime

Closes #43

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-22 15:55:49 -06:00
parent a6964ee684
commit cfbebe02c4
5 changed files with 783 additions and 408 deletions

View File

@ -4,9 +4,12 @@ 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
from utils.timezone import now_chicago
from typing import Dict, List, Tuple, Set, Optional
from dataclasses import dataclass
@ -30,6 +33,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 +46,7 @@ class TransactionPriority:
@dataclass
class ConflictContender:
"""A team contending for a contested player."""
team_abbrev: str
wins: int
losses: int
@ -52,6 +57,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 +68,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 +78,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 +89,7 @@ class CancelledMove:
@dataclass
class ThawReport:
"""Complete thaw report for admin review."""
week: int
season: int
timestamp: datetime
@ -94,8 +103,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 +117,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 +126,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 +138,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 +153,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 +230,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 +250,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 +262,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 +283,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 +316,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
@ -297,14 +327,14 @@ class TransactionFreezeTask:
self.logger.warning("Could not get current league state")
return
now = datetime.now()
now = now_chicago()
self.logger.info(
f"Weekly loop check",
datetime=now.isoformat(),
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 +342,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 +367,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 +419,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 +492,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 +516,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 +528,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 +536,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 +560,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 +578,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 +606,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 +647,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 +672,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 +683,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 +705,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 +754,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 +772,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 +795,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 +809,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 +818,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 +830,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 +849,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 +896,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
@ -835,17 +922,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 +943,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 +958,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 +969,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 +989,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))
embed.color = discord.Color(
int(this_team.color.replace("#", ""), 16)
)
except:
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 +1019,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 +1034,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 +1081,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 +1126,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 +1137,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*")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,129 @@
"""
Tests for timezone utility module.
Validates centralized timezone helpers that ensure scheduling logic uses
explicit America/Chicago timezone rather than relying on OS defaults.
"""
import pytest
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from utils.timezone import (
CHICAGO_TZ,
now_utc,
now_chicago,
to_chicago,
to_discord_timestamp,
)
class TestChicagoTZ:
"""Tests for the CHICAGO_TZ constant."""
def test_chicago_tz_is_zoneinfo(self):
"""CHICAGO_TZ should be a ZoneInfo instance for America/Chicago."""
assert isinstance(CHICAGO_TZ, ZoneInfo)
assert str(CHICAGO_TZ) == "America/Chicago"
class TestNowUtc:
"""Tests for now_utc()."""
def test_returns_aware_datetime(self):
"""now_utc() should return a timezone-aware datetime."""
result = now_utc()
assert result.tzinfo is not None
def test_returns_utc(self):
"""now_utc() should return a datetime in UTC."""
result = now_utc()
assert result.tzinfo == timezone.utc
class TestNowChicago:
"""Tests for now_chicago()."""
def test_returns_aware_datetime(self):
"""now_chicago() should return a timezone-aware datetime."""
result = now_chicago()
assert result.tzinfo is not None
def test_returns_chicago_tz(self):
"""now_chicago() should return a datetime in America/Chicago timezone."""
result = now_chicago()
assert result.tzinfo == CHICAGO_TZ
class TestToChicago:
"""Tests for to_chicago()."""
def test_converts_utc_datetime(self):
"""to_chicago() should correctly convert a UTC datetime to Chicago time."""
# 2024-07-15 18:00 UTC = 2024-07-15 13:00 CDT (UTC-5 during summer)
utc_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
result = to_chicago(utc_dt)
assert result.hour == 13
assert result.tzinfo == CHICAGO_TZ
def test_handles_naive_datetime_assumes_utc(self):
"""to_chicago() should treat naive datetimes as UTC."""
naive_dt = datetime(2024, 7, 15, 18, 0, 0)
result = to_chicago(naive_dt)
# Same as converting from UTC
utc_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
expected = to_chicago(utc_dt)
assert result.hour == expected.hour
assert result.tzinfo == CHICAGO_TZ
def test_preserves_already_chicago(self):
"""to_chicago() on an already-Chicago datetime should be a no-op."""
chicago_dt = datetime(2024, 7, 15, 13, 0, 0, tzinfo=CHICAGO_TZ)
result = to_chicago(chicago_dt)
assert result.hour == 13
assert result.tzinfo == CHICAGO_TZ
def test_winter_offset(self):
"""to_chicago() should use CST (UTC-6) during winter months."""
# 2024-01-15 18:00 UTC = 2024-01-15 12:00 CST (UTC-6 during winter)
utc_dt = datetime(2024, 1, 15, 18, 0, 0, tzinfo=timezone.utc)
result = to_chicago(utc_dt)
assert result.hour == 12
class TestToDiscordTimestamp:
"""Tests for to_discord_timestamp()."""
def test_default_style(self):
"""to_discord_timestamp() should use 'f' style by default."""
dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
result = to_discord_timestamp(dt)
unix_ts = int(dt.timestamp())
assert result == f"<t:{unix_ts}:f>"
def test_relative_style(self):
"""to_discord_timestamp() with style='R' should produce relative format."""
dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
result = to_discord_timestamp(dt, style="R")
unix_ts = int(dt.timestamp())
assert result == f"<t:{unix_ts}:R>"
def test_all_styles(self):
"""to_discord_timestamp() should support all Discord timestamp styles."""
dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
unix_ts = int(dt.timestamp())
for style in ("R", "f", "F", "t", "T", "d", "D"):
result = to_discord_timestamp(dt, style=style)
assert result == f"<t:{unix_ts}:{style}>"
def test_naive_datetime_assumes_utc(self):
"""to_discord_timestamp() should treat naive datetimes as UTC."""
naive_dt = datetime(2024, 7, 15, 18, 0, 0)
aware_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
assert to_discord_timestamp(naive_dt) == to_discord_timestamp(aware_dt)
def test_chicago_datetime_same_instant(self):
"""to_discord_timestamp() should produce the same unix timestamp regardless of tz."""
utc_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
chicago_dt = utc_dt.astimezone(CHICAGO_TZ)
assert to_discord_timestamp(utc_dt) == to_discord_timestamp(chicago_dt)

View File

@ -10,7 +10,7 @@ import json
import logging
import time
import uuid
from datetime import datetime
from datetime import datetime, UTC
from typing import Dict, Any, Optional, Union
# Context variable for request tracking across async calls
@ -32,7 +32,7 @@ class JSONFormatter(logging.Formatter):
"""Format log record as JSON with context information."""
# Base log object
log_obj: dict[str, JSONValue] = {
"timestamp": datetime.now().isoformat() + "Z",
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),

54
utils/timezone.py Normal file
View File

@ -0,0 +1,54 @@
"""
Timezone Utilities
Centralized timezone handling for the Discord bot. The SBA league operates
in America/Chicago time, but the production container may have ambiguous
timezone config. These helpers ensure scheduling logic uses explicit timezones
rather than relying on the OS default.
- Internal storage/logging: UTC
- Scheduling checks (freeze/thaw): America/Chicago
"""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# League timezone — all scheduling decisions use this
CHICAGO_TZ = ZoneInfo("America/Chicago")
def now_utc() -> datetime:
"""Return the current time as a timezone-aware UTC datetime."""
return datetime.now(timezone.utc)
def now_chicago() -> datetime:
"""Return the current time as a timezone-aware America/Chicago datetime."""
return datetime.now(CHICAGO_TZ)
def to_chicago(dt: datetime) -> datetime:
"""Convert a datetime to America/Chicago.
If *dt* is naive (no tzinfo), it is assumed to be UTC.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(CHICAGO_TZ)
def to_discord_timestamp(dt: datetime, style: str = "f") -> str:
"""Format a datetime as a Discord dynamic timestamp.
Args:
dt: A datetime (naive datetimes are assumed UTC).
style: Discord timestamp style letter.
R = relative, f = long date/short time, F = long date/time,
t = short time, T = long time, d = short date, D = long date.
Returns:
A string like ``<t:1234567890:f>``.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return f"<t:{int(dt.timestamp())}:{style}>"