fix: use explicit America/Chicago timezone for freeze/thaw scheduling
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m16s
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m16s
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
a668a3505c
commit
8a1a957c2a
@ -8,6 +8,8 @@ Runs on a schedule to increment weeks and process contested transactions.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, UTC
|
from datetime import datetime, UTC
|
||||||
|
|
||||||
|
from utils.timezone import now_chicago
|
||||||
from typing import Dict, List, Tuple, Set, Optional
|
from typing import Dict, List, Tuple, Set, Optional
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@ -325,7 +327,7 @@ class TransactionFreezeTask:
|
|||||||
self.logger.warning("Could not get current league state")
|
self.logger.warning("Could not get current league state")
|
||||||
return
|
return
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = now_chicago()
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Weekly loop check",
|
f"Weekly loop check",
|
||||||
datetime=now.isoformat(),
|
datetime=now.isoformat(),
|
||||||
|
|||||||
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 logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, UTC
|
||||||
from typing import Dict, Any, Optional, Union
|
from typing import Dict, Any, Optional, Union
|
||||||
|
|
||||||
# Context variable for request tracking across async calls
|
# Context variable for request tracking across async calls
|
||||||
@ -32,7 +32,7 @@ class JSONFormatter(logging.Formatter):
|
|||||||
"""Format log record as JSON with context information."""
|
"""Format log record as JSON with context information."""
|
||||||
# Base log object
|
# Base log object
|
||||||
log_obj: dict[str, JSONValue] = {
|
log_obj: dict[str, JSONValue] = {
|
||||||
"timestamp": datetime.now().isoformat() + "Z",
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"level": record.levelname,
|
"level": record.levelname,
|
||||||
"logger": record.name,
|
"logger": record.name,
|
||||||
"message": record.getMessage(),
|
"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