refactor(datetime): introduce UtcDateTime, remove naive-strip workarounds

Adds a UtcDateTime TypeDecorator (models.py) that enforces aware-UTC on
write and stamps tzinfo=UTC on read. Replaces 26 DateTime column
declarations. Removes 5 production sites that defensively stripped tzinfo
to match SQLite's lossy round-trip. auth.py now coerces legacy session
cookies upward (stamp UTC on parsed naive marker) instead of stripping
live aware markers downward.

The change is Python-side only: UtcDateTime.impl = DateTime, so DDL and
emitted SQL are unchanged. No Alembic migration needed.

Adds 2 unit tests in test_models.py pinning the decorator's contract
independently of the column declarations.

The three deliberately-naive test_timeago.py fixtures (lines 67, 73, 113)
remain naive on purpose -- they exercise _ensure_utc's normalize-up path
at the public filter boundary, which stays as belt-and-braces defense.

See docs/superpowers/specs/2026-05-16-tz-aware-datetime-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-16 11:59:29 +02:00
parent a5436deaf0
commit 18113637e9
No known key found for this signature in database
7 changed files with 83 additions and 39 deletions

View file

@ -1,4 +1,4 @@
from datetime import datetime from datetime import UTC, datetime
from functools import wraps from functools import wraps
from typing import Callable, TypeVar from typing import Callable, TypeVar
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
@ -53,11 +53,12 @@ def load_current_user() -> None:
except ValueError: except ValueError:
g.user = None g.user = None
return return
# user.password_changed_at comes back naive from SQLite; strip tz from the # Legacy sessions minted before the UtcDateTime migration carry naive
# marker so an aware-marker session (just stamped from an in-memory user) # ISO markers; stamp UTC so the comparison against the now-aware DB
# compares cleanly with a freshly-loaded user row. # value works. Cheap permanent defense; no reliable signal for "all
if marker_dt.tzinfo is not None: # naive cookies have expired."
marker_dt = marker_dt.replace(tzinfo=None) if marker_dt.tzinfo is None:
marker_dt = marker_dt.replace(tzinfo=UTC)
if marker_dt < user.password_changed_at: if marker_dt < user.password_changed_at:
g.user = None g.user = None
return return

View file

@ -9,6 +9,7 @@ from sqlalchemy import (
Integer, Integer,
String, String,
Text, Text,
TypeDecorator,
UniqueConstraint, UniqueConstraint,
text, text,
) )
@ -23,6 +24,34 @@ def now_utc() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)
class UtcDateTime(TypeDecorator):
"""Always store and surface UTC-aware datetimes.
SQLite has no native tz-aware type and pysqlite strips tzinfo on
round-trip. We convert inputs to UTC, persist them as naive UTC under
the hood, and re-stamp tzinfo=UTC on read. The result: every datetime
on a model attribute is aware UTC, always.
"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
if value.tzinfo is None:
raise TypeError(
"naive datetime passed to UtcDateTime column; "
"all writes must be UTC-aware"
)
return value.astimezone(UTC).replace(tzinfo=None)
def process_result_value(self, value, dialect):
if value is None:
return None
return value.replace(tzinfo=UTC)
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@ -33,9 +62,9 @@ class User(Base):
active: Mapped[bool] = mapped_column( active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False, server_default=text("1"), Boolean, default=True, nullable=False, server_default=text("1"),
) )
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
password_changed_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) password_changed_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
class Overlay(Base): class Overlay(Base):
@ -65,8 +94,8 @@ class Overlay(Base):
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
script: Mapped[str] = mapped_column(Text, default="", nullable=False) script: Mapped[str] = mapped_column(Text, default="", nullable=False)
last_build_status: Mapped[str] = mapped_column(String(16), default="", nullable=False) last_build_status: Mapped[str] = mapped_column(String(16), default="", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
class WorkshopItem(Base): class WorkshopItem(Base):
@ -80,10 +109,10 @@ class WorkshopItem(Base):
file_size: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False) file_size: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
time_updated: Mapped[int] = mapped_column(Integer, default=0, nullable=False) time_updated: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
preview_url: Mapped[str] = mapped_column(Text, default="", nullable=False) preview_url: Mapped[str] = mapped_column(Text, default="", nullable=False)
last_downloaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_downloaded_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False) last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
class OverlayWorkshopItem(Base): class OverlayWorkshopItem(Base):
@ -110,8 +139,8 @@ class Blueprint(Base):
name: Mapped[str] = mapped_column(String(128), nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False)
arguments: Mapped[str] = mapped_column(Text, default="[]", nullable=False) arguments: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
config: Mapped[str] = mapped_column(Text, default="[]", nullable=False) config: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
class BlueprintOverlay(Base): class BlueprintOverlay(Base):
@ -124,8 +153,8 @@ class BlueprintOverlay(Base):
expose_server_cfg: Mapped[bool] = mapped_column( expose_server_cfg: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("0") Boolean, nullable=False, default=False, server_default=text("0")
) )
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
class Server(Base): class Server(Base):
@ -141,7 +170,7 @@ class Server(Base):
port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False) port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
desired_state: Mapped[str] = mapped_column(String(16), default="stopped", nullable=False) desired_state: Mapped[str] = mapped_column(String(16), default="stopped", nullable=False)
actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False) actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)
actual_state_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) actual_state_updated_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False) last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
rcon_password: Mapped[str] = mapped_column( rcon_password: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default="" String(64), nullable=False, default="", server_default=""
@ -149,8 +178,8 @@ class Server(Base):
hostname: Mapped[str] = mapped_column( hostname: Mapped[str] = mapped_column(
String(128), nullable=False, default="", server_default="" String(128), nullable=False, default="", server_default=""
) )
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
class Job(Base): class Job(Base):
@ -163,10 +192,10 @@ class Job(Base):
operation: Mapped[str] = mapped_column(String(32), nullable=False) operation: Mapped[str] = mapped_column(String(32), nullable=False)
state: Mapped[str] = mapped_column(String(16), default="queued", nullable=False) state: Mapped[str] = mapped_column(String(16), default="queued", nullable=False)
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True) exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) started_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) finished_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
class JobLog(Base): class JobLog(Base):
@ -177,7 +206,7 @@ class JobLog(Base):
seq: Mapped[int] = mapped_column(Integer, nullable=False) seq: Mapped[int] = mapped_column(Integer, nullable=False)
stream: Mapped[str] = mapped_column(String(8), nullable=False) stream: Mapped[str] = mapped_column(String(8), nullable=False)
line: Mapped[str] = mapped_column(Text, nullable=False) line: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
class ServerLiveState(Base): class ServerLiveState(Base):
@ -191,8 +220,8 @@ class ServerLiveState(Base):
server_id: Mapped[int] = mapped_column( server_id: Mapped[int] = mapped_column(
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
) )
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) started_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) last_seen_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False)
players: Mapped[int] = mapped_column(Integer, nullable=False) players: Mapped[int] = mapped_column(Integer, nullable=False)
max_players: Mapped[int] = mapped_column(Integer, nullable=False) max_players: Mapped[int] = mapped_column(Integer, nullable=False)
bots: Mapped[int] = mapped_column(Integer, nullable=False) bots: Mapped[int] = mapped_column(Integer, nullable=False)
@ -213,8 +242,8 @@ class ServerPlayerSession(Base):
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
) )
steam_id_64: Mapped[str] = mapped_column(String(20), nullable=False) steam_id_64: Mapped[str] = mapped_column(String(20), nullable=False)
joined_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) joined_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False)
left_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) left_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
name_at_join: Mapped[str] = mapped_column(String(64), nullable=False) name_at_join: Mapped[str] = mapped_column(String(64), nullable=False)
min_ping: Mapped[int] = mapped_column(Integer, nullable=False) min_ping: Mapped[int] = mapped_column(Integer, nullable=False)
max_ping: Mapped[int] = mapped_column(Integer, nullable=False) max_ping: Mapped[int] = mapped_column(Integer, nullable=False)
@ -226,7 +255,7 @@ class SteamUserProfile(Base):
steam_id_64: Mapped[str] = mapped_column(String(20), primary_key=True) steam_id_64: Mapped[str] = mapped_column(String(20), primary_key=True)
persona_name: Mapped[str] = mapped_column(String(64), nullable=False) persona_name: Mapped[str] = mapped_column(String(64), nullable=False)
avatar_url: Mapped[str] = mapped_column(Text, nullable=False) avatar_url: Mapped[str] = mapped_column(Text, nullable=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) fetched_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False)
class CommandHistory(Base): class CommandHistory(Base):
@ -248,5 +277,5 @@ class CommandHistory(Base):
Boolean, nullable=False, default=False, server_default=text("0") Boolean, nullable=False, default=False, server_default=text("0")
) )
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=now_utc, nullable=False UtcDateTime, default=now_utc, nullable=False
) )

View file

@ -171,7 +171,7 @@ def servers_page() -> str:
).all() ).all()
stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30) stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30)
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds) cutoff = datetime.now(UTC) - timedelta(seconds=stale_seconds)
server_ids = [s.id for s, _bp in rows] server_ids = [s.id for s, _bp in rows]
latest_rows: dict[int, ServerLiveState] = {} latest_rows: dict[int, ServerLiveState] = {}

View file

@ -83,9 +83,7 @@ def profile_password_change() -> Response:
if user is None or not verify_password(current_password, user.password_digest): if user is None or not verify_password(current_password, user.password_digest):
return _redirect_with_error("wrong_current") return _redirect_with_error("wrong_current")
user.password_digest = hash_password(new_password) user.password_digest = hash_password(new_password)
# Strip tz so the marker matches what a subsequent DB read returns user.password_changed_at = now_utc()
# (SQLite DateTime columns don't preserve tzinfo).
user.password_changed_at = now_utc().replace(tzinfo=None)
new_marker = user.password_changed_at.isoformat() new_marker = user.password_changed_at.isoformat()
session["pw_changed_at"] = new_marker session["pw_changed_at"] = new_marker

View file

@ -207,7 +207,7 @@ def live_state_fragment(server_id: int) -> Response:
return Response(status=404) return Response(status=404)
stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30) stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30)
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds) cutoff = datetime.now(UTC) - timedelta(seconds=stale_seconds)
latest = db.scalar( latest = db.scalar(
select(ServerLiveState) select(ServerLiveState)
@ -231,7 +231,7 @@ def live_state_fragment(server_id: int) -> Response:
current_ids = [r[0].steam_id_64 for r in current_rows] current_ids = [r[0].steam_id_64 for r in current_rows]
recent_cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta( recent_cutoff = datetime.now(UTC) - timedelta(
days=current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30) days=current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30)
) )

View file

@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
def _now() -> datetime: def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None) return datetime.now(UTC)
def poll_once() -> None: def poll_once() -> None:

View file

@ -1,3 +1,6 @@
from datetime import UTC, datetime
import pytest
from sqlalchemy import select from sqlalchemy import select
from l4d2web.db import init_db, session_scope from l4d2web.db import init_db, session_scope
@ -8,6 +11,7 @@ from l4d2web.models import (
ServerPlayerSession, ServerPlayerSession,
SteamUserProfile, SteamUserProfile,
User, User,
UtcDateTime,
now_utc as now_utc_aware, now_utc as now_utc_aware,
) )
@ -108,3 +112,15 @@ def test_steam_user_profile_table_columns(tmp_path, monkeypatch) -> None:
fetched_at=now_utc_aware(), fetched_at=now_utc_aware(),
) )
db.add(row); db.flush() db.add(row); db.flush()
def test_utc_datetime_rejects_naive_bind() -> None:
with pytest.raises(TypeError, match="naive"):
UtcDateTime().process_bind_param(datetime(2026, 5, 16, 12, 0), None)
def test_utc_datetime_stamps_utc_on_read() -> None:
naive = datetime(2026, 5, 16, 12, 0)
result = UtcDateTime().process_result_value(naive, None)
assert result == datetime(2026, 5, 16, 12, 0, tzinfo=UTC)
assert result.tzinfo == UTC