Merge pull request 'next-release' (#47) from next-release into main
All checks were successful
Build Docker Image / build (push) Successful in 46s

Reviewed-on: #47
This commit is contained in:
cal 2026-02-22 22:33:35 +00:00
commit a80addc742
6 changed files with 522 additions and 254 deletions

View File

@ -59,6 +59,13 @@ class MyCog(commands.Cog):
- **Image**: `manticorum67/major-domo-discordapp` (no dash between discord and app) - **Image**: `manticorum67/major-domo-discordapp` (no dash between discord and app)
- **Health**: Process liveness only (no HTTP endpoint) - **Health**: Process liveness only (no HTTP endpoint)
- **CI/CD**: Gitea Actions on PR to `main` — builds Docker image, auto-generates CalVer version (`YYYY.MM.BUILD`) on merge - **CI/CD**: Gitea Actions on PR to `main` — builds Docker image, auto-generates CalVer version (`YYYY.MM.BUILD`) on merge
### Release Workflow
1. Create feature/fix branches off `next-release` (e.g., `fix/scorebug-bugs`)
2. When done, merge the branch into `next-release` — this is the staging branch where changes accumulate
3. When ready to release, open a PR from `next-release``main`
4. CI builds Docker image on PR; CalVer tag is created on merge
5. Deploy the new image to production (see `/deploy` skill)
- **Other services on same host**: `sba_db_api`, `sba_postgres`, `sba_redis`, `sba-website-sba-web-1`, `pd_api` - **Other services on same host**: `sba_db_api`, `sba_postgres`, `sba_redis`, `sba-website-sba-web-1`, `pd_api`
### Logs ### Logs

View File

@ -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(),
@ -920,6 +922,7 @@ class TransactionFreezeTask:
is_div_week = current.week in [1, 3, 6, 14, 16, 18] is_div_week = current.week in [1, 3, 6, 14, 16, 18]
weekly_str = ( weekly_str = (
f"**Week**: {current.week}\n"
f"**Season**: {season_str}\n" f"**Season**: {season_str}\n"
f"**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / " f"**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / "
f"{night_str} / {day_str}" f"{night_str} / {day_str}"

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 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
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}>"