fix: use explicit America/Chicago timezone for freeze/thaw scheduling
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m23s
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:
parent
a6964ee684
commit
cfbebe02c4
@ -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
129
tests/test_utils_timezone.py
Normal file
129
tests/test_utils_timezone.py
Normal 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)
|
||||
@ -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
54
utils/timezone.py
Normal 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}>"
|
||||
Loading…
Reference in New Issue
Block a user