Compare commits

..

6 commits

Author SHA1 Message Date
mwiegand
e5ce4e9fc8
chore(envrc): switch direnv from use uv to layout uv
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:20:16 +02:00
mwiegand
0c552082dc
spec(tz-aware-datetime): correct speculative l4d2host carve-out
The previous version implied l4d2host has tz patterns to defer. An
inventory grep showed it has no datetime usage at all (no `from datetime`
import anywhere in the tree). Replace the bullet with the verified
finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:01:25 +02:00
mwiegand
18113637e9
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>
2026-05-16 11:59:29 +02:00
mwiegand
a5436deaf0
test(datetime): pin tz-aware contract for fixtures (red until UtcDateTime lands)
Drops .replace(tzinfo=None) from 8 fixture sites that mirrored the
production-side strip convention. Two of these (test_live_state_poller.py
test_new_player_opens_session_with_backfilled_join, test_models.py
test_user_has_password_changed_at_default) now fail with TypeError when
comparing aware in-memory values against naive DB reads -- that failure
is intentional and describes the contract commit 2 must satisfy:
DB-sourced datetimes return aware UTC.

The remaining 6 sites were already cosmetic (fixture-seed only, no
aware-vs-DB comparison) but are flipped here so future authors write
aware fixtures.

The three deliberately-naive sites in test_timeago.py (lines 67, 73,
113) are LEFT untouched -- they exercise _ensure_utc's normalize-up
path and are feature tests, not workarounds.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:55:48 +02:00
mwiegand
99b528e563
spec(tz-aware-datetime): design for UtcDateTime migration
Validated design for the migration framed by the 2026-05-16 handoff doc.
Two findings shaped this design: (1) DateTime(timezone=True) is a no-op
on SQLite per the round-trip spike, so the fix must live in app code;
(2) every byte on disk is provably UTC (no datetime.now() / utcnow() /
CURRENT_TIMESTAMP / func.now() anywhere), so a result-side tzinfo stamp
is correct, not optimistic.

The chosen approach: a UtcDateTime TypeDecorator that raises on naive
bind and stamps tzinfo=UTC on read. Single PR, two commits (test-first
for clean bisect). No DDL change, no Alembic migration, no on-disk
data transform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:53:22 +02:00
mwiegand
dafd1d7f15
chore(gitignore): ignore .tmp/ scratch directory
Used for one-off spikes and scratch scripts during development; should
never be committed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:53:14 +02:00
13 changed files with 367 additions and 48 deletions

2
.envrc
View file

@ -1 +1 @@
use uv layout uv

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ l4d2web.db*
.superpowers/ .superpowers/
*.db *.db
opencode.json opencode.json
.tmp/

View file

@ -0,0 +1,274 @@
# Timezone-aware datetime migration — design
Replaces the `.replace(tzinfo=None)` convention in `l4d2web` with a
`UtcDateTime` SQLAlchemy `TypeDecorator` that enforces aware-UTC on every
column boundary. The handoff at
`docs/superpowers/specs/2026-05-16-tz-aware-datetime-handoff.md` framed
the problem; this doc is the validated design.
## Context
`l4d2web`'s `l4d2web/models.py` declares 26 `DateTime` columns. SQLAlchemy
talking to pysqlite strips `tzinfo` on write and returns naive datetimes on
read, so a documented convention emerged: *every datetime in this codebase
is UTC; we just don't say so in the type system.* The convention is correct
in practice — every write path goes through `now_utc()` (`models.py:22-23`)
or `datetime.now(UTC)` directly — but it is held together by author
discipline, not by any compiler or runtime check.
The cost shows up as ~16 defensive `.replace(tzinfo=None)` sites scattered
across routes, services, and tests, plus a permanent mental tax: a
`datetime` parameter could be aware or naive depending on where it came
from, and a wrong guess raises `TypeError` at runtime — or, worse, silently
treats a non-UTC value as UTC.
## Validation work that shaped this design
Two findings reshaped the originally-imagined fix.
### The `timezone=True` kwarg is a no-op on SQLite
The handoff's implicit plan ("flip `timezone=True` on every column and the
strip sites can come down") is dead. A spike script
(`.tmp/tz_spike.py`, to be deleted with this PR) demonstrated:
| Column type | Write input | On-disk text | Read output |
|---|---|---|---|
| `DateTime` | aware UTC | `'2026-05-16 09:23:47.561010'` | naive |
| `DateTime(timezone=True)` | aware UTC | `'2026-05-16 09:23:47.564949'` | **naive** |
| `DateTime(timezone=True)` | legacy naive on disk | `'2026-05-16 12:34:56.789012'` | naive → `TypeError` on comparison with `datetime.now(UTC)` |
`pysqlite` has no native timestamp-with-zone type; the kwarg only affects
DDL emission, not pysqlite's serialization. The fix must live in app code.
### Every byte on disk is UTC
Greps for every conceivable non-UTC write site returned empty:
```
$ grep -rn 'datetime.now()' l4d2web/ → no matches
$ grep -rn 'utcnow' l4d2web/ → no matches
$ grep -rn 'CURRENT_TIMESTAMP' l4d2web/ → no matches
$ grep -rn 'func.now\|func.current' l4d2web/ → no matches
```
So `process_result_value` stamping `tzinfo=UTC` on read is *correct*, not
optimistic — no data audit, no backfill, no migration to rewrite rows.
## The contract — `UtcDateTime`
```python
from sqlalchemy.types import TypeDecorator
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)
```
The class lives in `l4d2web/models.py` (single consumer; a separate module
for ~25 lines is over-structuring for this project).
### Why raise on naive input rather than silently coerce
Silent coercion is exactly how this codebase grew the reverse-strip line in
`auth.py:60`. Once you tolerate ambiguity at the boundary, the ambiguity
leaks into every comparison site. A loud failure teaches the next author
that the column is aware-only; silent coercion enables future drift.
The runtime check is also the *only* enforcement available — there is no
mypy plugin or static check for "naive datetime crossing into SA bind."
### Why `impl = DateTime` (not `DateTime(timezone=True)`)
`UtcDateTime.impl = DateTime` produces SQL identical to the current
schema. `alembic revision --autogenerate` sees no difference. Historical
migrations remain accurate descriptions of past DDL. No new migration
file is needed; the change is purely Python-side.
(Aside: on PostgreSQL, `impl = DateTime(timezone=True)` would use native
`timestamptz`. Switching dialects would be a one-line change. Not in
scope here.)
## Migration shape
**One PR, two commits.**
1. `test(datetime): pin tz-aware contract for fixtures (red until UtcDateTime lands)`
— drop `.replace(tzinfo=None)` from 10 test sites. Suite goes red.
These failing tests are an executable spec for commit 2.
2. `refactor(datetime): introduce UtcDateTime, remove naive-strip workarounds`
— add the class, flip 26 column declarations, drop 5 production strip
sites + their explanatory comment, flip one line in `auth.py` from
strip-live-marker to stamp-legacy-cookie, add 2 unit tests pinning the
decorator contract. Tests go green.
The test-first ordering gives a clean bisect target: commit 2 is the
single causal point where the type-system invariant takes hold. The
project's recent commits show a strong preference for atomic
single-purpose commits, which this ordering matches.
## What stays the same
- `now_utc()` (`models.py:22-23`) — already returns aware UTC.
- On-disk format — identical bytes; pysqlite still writes `'YYYY-MM-DD
HH:MM:SS.ffffff'`.
- Schema (DDL) — `UtcDateTime.impl = DateTime` produces identical CREATE
TABLE statements.
- SQL emitted at query time — bind hook still hands the driver naive
datetimes.
- `services/timeago.py::_ensure_utc` — kept as a public-filter boundary
assertion. Still exercised by `tests/test_timeago.py:113`, which
deliberately passes a naive `datetime(2026, 5, 16, 14, 32, 11)` to
test the normalize-up path. Load-bearing-ness for DB values drains
away; defense-in-depth at the rendering boundary remains.
- The Alembic chain in `l4d2web/alembic/versions/` — no new migration
file. (See [decision #4](#decisions-locked).)
## What changes
### Call sites that lose `.replace(tzinfo=None)`
5 production sites + 1 `_now()` helper:
- `routes/profile_routes.py:88`
- `routes/server_routes.py:210, :234`
- `routes/page_routes.py:174`
- `services/live_state_poller.py:37` (the `_now()` helper just
returns `datetime.now(UTC)`)
10 test sites: `tests/test_auth.py:162`, `tests/test_timeago.py:67, :73`
(NOT `:113`), `tests/test_servers.py:459, :521`,
`tests/test_live_state_poller.py:159, :278, :303, :356`,
`tests/test_models.py:43`.
The explanatory comment at `routes/profile_routes.py:86-87` is deleted —
it documented a workaround that no longer exists.
### `auth.py` legacy-cookie compat
`auth.py:59-60` today strips tz from a freshly-stamped aware marker so it
compares cleanly with naive DB. After migration the inverse is needed:
legacy session cookies (minted before the deploy) carry naive ISO; the DB
now returns aware.
```python
# Before
if marker_dt.tzinfo is not None:
marker_dt = marker_dt.replace(tzinfo=None)
# After
# Legacy sessions carry naive ISO markers; stamp UTC so comparison works.
if marker_dt.tzinfo is None:
marker_dt = marker_dt.replace(tzinfo=UTC)
```
The `UTC` symbol must be added to the `from datetime import datetime`
import on `auth.py:1` — easy to miss in review.
This line stays permanently. There is no reliable signal for "all naive
cookies have expired"; the line is cheap defense and mirrors the
`_ensure_utc` philosophy at the rendering boundary.
### Unit tests pinning the decorator contract
Two tests in `tests/test_models.py`, surviving any future column rename:
```python
def test_utc_datetime_rejects_naive_bind():
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():
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
```
### Already-aware writes that "just keep working"
`services/job_worker.py` lines 131, 486, 522, 550, 579 and
`services/overlay_builders.py:233` already write aware
`datetime.now(UTC)` directly to columns. Today they "work" because
pysqlite implicitly strips. After migration they pass `process_bind_param`
explicitly. No code change needed at these sites — but worth noting they
exist, because they're evidence that the codebase has been writing aware
datetimes all along, just losing the tzinfo on the wire.
## Decisions locked
| # | Decision | Rationale |
|---|---|---|
| 1 | `TypeDecorator`, not `DateTime(timezone=True)` | Spike proved the kwarg is a no-op on SQLite. |
| 2 | Raise on naive bind, not silent coerce | Loud failures teach; silent coercion is how `auth.py:60` happened. |
| 3 | Test-first two-commit ordering | Clean bisect target; commit 1 is an executable spec for commit 2. |
| 4 | No new Alembic migration | `impl = DateTime` produces identical SQL; an `alter_column` migration would be a no-op on SQLite and generate autogenerate noise; an empty marker migration is documentation in the wrong place. |
| 5 | Legacy-cookie compat in `auth.py` is permanent | No reliable signal for cookie rollover; one line of defense matches `_ensure_utc`. |
| 6 | `services/timeago.py::_ensure_utc` stays | Public filter boundary + tested at `test_timeago.py:113`. |
| 7 | `UtcDateTime` lives in `models.py` | Single consumer; separate module for 25 lines is over-structuring. |
## Out of scope
- Postgres portability. `UtcDateTime` would adapt with `impl =
DateTime(timezone=True)` if the dialect supports it, but switching
dialects is its own project.
- `l4d2host` (the dedicated-server CLI side of the repo) has no datetime
usage — no `from datetime` import anywhere in the tree, verified by
grep. The scope boundary is documentary; no follow-up work exists.
- Static enforcement (mypy plugin, ruff rule) for "naive datetime crossing
into a `UtcDateTime` column." The runtime `TypeError` is the gate; a
static check would be a nice-to-have.
## Verification
1. **Unit:** the two new tests in `tests/test_models.py` pin the
decorator's contract independently of the model declarations.
2. **Suite:** `cd l4d2web && uv run pytest`. Expected: all-green after
commit 2.
3. **Smoke (manual):** log in, change password, log out, log back in.
Exercises the full session-cookie round-trip and the
`password_changed_at` comparison at `auth.py:61`.
4. **Legacy-cookie path (manual):** craft a session with a naive ISO
marker and hit any `@require_login` route. Expected: clean
comparison, not a 500.
## Pointers
- `docs/superpowers/specs/2026-05-16-tz-aware-datetime-handoff.md` — the
handoff that framed this work.
- `docs/superpowers/specs/2026-05-16-timeago-shared-display-design.md`
the parent project that surfaced the convention's cost.
- `l4d2web/l4d2web/models.py:22-23``now_utc()` factory (no change).
- `l4d2web/l4d2web/services/timeago.py:12-15``_ensure_utc` boundary
(no change).
- `l4d2web/l4d2web/auth.py:51-60` — the compat block whose direction
flips.

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

@ -159,7 +159,7 @@ def test_load_current_user_rejects_stale_marker(client) -> None:
db.flush() db.flush()
uid = u.id uid = u.id
stale = datetime.now(UTC).replace(tzinfo=None) - timedelta(minutes=5) stale = datetime.now(UTC) - timedelta(minutes=5)
with client.session_transaction() as sess: with client.session_transaction() as sess:
sess["user_id"] = uid sess["user_id"] = uid
sess["pw_changed_at"] = stale.isoformat() sess["pw_changed_at"] = stale.isoformat()

View file

@ -156,7 +156,7 @@ def test_new_player_opens_session_with_backfilled_join(tmp_path, monkeypatch) ->
assert s.min_ping == 60 assert s.min_ping == 60
assert s.max_ping == 60 assert s.max_ping == 60
# joined_at should be ~30s before now # joined_at should be ~30s before now
delta = (datetime.now(UTC).replace(tzinfo=None) - s.joined_at).total_seconds() delta = (datetime.now(UTC) - s.joined_at).total_seconds()
assert 25 <= delta <= 60 assert 25 <= delta <= 60
@ -275,7 +275,7 @@ def test_skips_enrichment_when_api_key_unset(tmp_path, monkeypatch) -> None:
def test_retention_trims_old_rows(tmp_path, monkeypatch) -> None: def test_retention_trims_old_rows(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path) app, sid = _seed(tmp_path)
app.config["LIVE_STATE_HISTORY_DAYS"] = 30 app.config["LIVE_STATE_HISTORY_DAYS"] = 30
long_ago = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=45) long_ago = datetime.now(UTC) - timedelta(days=45)
with session_scope() as db: with session_scope() as db:
db.add(ServerLiveState( db.add(ServerLiveState(
server_id=sid, started_at=long_ago, last_seen_at=long_ago, server_id=sid, started_at=long_ago, last_seen_at=long_ago,
@ -300,7 +300,7 @@ def test_retention_trims_old_rows(tmp_path, monkeypatch) -> None:
def test_close_stuck_sessions_after_threshold(tmp_path, monkeypatch) -> None: def test_close_stuck_sessions_after_threshold(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path) app, sid = _seed(tmp_path)
app.config["STUCK_SESSION_SECONDS"] = 60 app.config["STUCK_SESSION_SECONDS"] = 60
way_back = datetime.now(UTC).replace(tzinfo=None) - timedelta(hours=2) way_back = datetime.now(UTC) - timedelta(hours=2)
with session_scope() as db: with session_scope() as db:
db.add(ServerPlayerSession( db.add(ServerPlayerSession(
server_id=sid, steam_id_64="76561197960828710", server_id=sid, steam_id_64="76561197960828710",
@ -353,7 +353,7 @@ def test_skips_enrichment_when_cache_is_fresh(tmp_path, monkeypatch) -> None:
steam_id_64="76561197960828710", steam_id_64="76561197960828710",
persona_name="cached", persona_name="cached",
avatar_url="cached.jpg", avatar_url="cached.jpg",
fetched_at=datetime.now(UTC).replace(tzinfo=None), fetched_at=datetime.now(UTC),
)) ))
monkeypatch.setattr( monkeypatch.setattr(
live_state_poller, "query_status", live_state_poller, "query_status",

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,
) )
@ -40,7 +44,7 @@ def test_user_has_password_changed_at_default(tmp_path, monkeypatch):
create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db() init_db()
before = datetime.now(UTC).replace(tzinfo=None) before = datetime.now(UTC)
with session_scope() as db: with session_scope() as db:
db.add(User(username="alice", password_digest=hash_password("secret"))) db.add(User(username="alice", password_digest=hash_password("secret")))
with session_scope() as db: with session_scope() as db:
@ -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

View file

@ -456,7 +456,7 @@ def test_servers_index_renders_live_state_badge(user_client_with_blueprints) ->
client, data = user_client_with_blueprints client, data = user_client_with_blueprints
# Seed one server with a recent snapshot, one without. # Seed one server with a recent snapshot, one without.
now = datetime.now(UTC).replace(tzinfo=None) now = datetime.now(UTC)
with session_scope() as db: with session_scope() as db:
s_active = Server( s_active = Server(
user_id=data["user_id"], user_id=data["user_id"],
@ -518,7 +518,7 @@ def test_live_state_fragment_renders_current_and_recent(user_client_with_bluepri
) )
client, data = user_client_with_blueprints client, data = user_client_with_blueprints
now = datetime.now(UTC).replace(tzinfo=None) now = datetime.now(UTC)
with session_scope() as db: with session_scope() as db:
srv = Server( srv = Server(
user_id=data["user_id"], blueprint_id=data["blueprint_id"], user_id=data["user_id"], blueprint_id=data["blueprint_id"],