Compare commits
No commits in common. "e5ce4e9fc8c9a2ad03dc09fbfffadb340b306a21" and "6cef55f900be88cf6aabf9e230e83a448f271262" have entirely different histories.
e5ce4e9fc8
...
6cef55f900
13 changed files with 48 additions and 367 deletions
2
.envrc
2
.envrc
|
|
@ -1 +1 @@
|
||||||
layout uv
|
use uv
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,4 +12,3 @@ l4d2web.db*
|
||||||
.superpowers/
|
.superpowers/
|
||||||
*.db
|
*.db
|
||||||
opencode.json
|
opencode.json
|
||||||
.tmp/
|
|
||||||
|
|
|
||||||
|
|
@ -1,274 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from datetime import UTC, datetime
|
from datetime import 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,12 +53,11 @@ def load_current_user() -> None:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
g.user = None
|
g.user = None
|
||||||
return
|
return
|
||||||
# Legacy sessions minted before the UtcDateTime migration carry naive
|
# user.password_changed_at comes back naive from SQLite; strip tz from the
|
||||||
# ISO markers; stamp UTC so the comparison against the now-aware DB
|
# marker so an aware-marker session (just stamped from an in-memory user)
|
||||||
# value works. Cheap permanent defense; no reliable signal for "all
|
# compares cleanly with a freshly-loaded user row.
|
||||||
# naive cookies have expired."
|
if marker_dt.tzinfo is not None:
|
||||||
if marker_dt.tzinfo is None:
|
marker_dt = marker_dt.replace(tzinfo=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
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from sqlalchemy import (
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
TypeDecorator,
|
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|
@ -24,34 +23,6 @@ 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"
|
||||||
|
|
||||||
|
|
@ -62,9 +33,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(UtcDateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
password_changed_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
password_changed_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Overlay(Base):
|
class Overlay(Base):
|
||||||
|
|
@ -94,8 +65,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(UtcDateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class WorkshopItem(Base):
|
class WorkshopItem(Base):
|
||||||
|
|
@ -109,10 +80,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(UtcDateTime, nullable=True)
|
last_downloaded_at: Mapped[datetime | None] = mapped_column(DateTime, 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(UtcDateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class OverlayWorkshopItem(Base):
|
class OverlayWorkshopItem(Base):
|
||||||
|
|
@ -139,8 +110,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(UtcDateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class BlueprintOverlay(Base):
|
class BlueprintOverlay(Base):
|
||||||
|
|
@ -153,8 +124,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(UtcDateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Server(Base):
|
class Server(Base):
|
||||||
|
|
@ -170,7 +141,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(UtcDateTime, nullable=True)
|
actual_state_updated_at: Mapped[datetime | None] = mapped_column(DateTime, 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=""
|
||||||
|
|
@ -178,8 +149,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(UtcDateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Job(Base):
|
class Job(Base):
|
||||||
|
|
@ -192,10 +163,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(UtcDateTime, nullable=True)
|
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
finished_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class JobLog(Base):
|
class JobLog(Base):
|
||||||
|
|
@ -206,7 +177,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(UtcDateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class ServerLiveState(Base):
|
class ServerLiveState(Base):
|
||||||
|
|
@ -220,8 +191,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(UtcDateTime, nullable=False)
|
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
last_seen_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False)
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime, 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)
|
||||||
|
|
@ -242,8 +213,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(UtcDateTime, nullable=False)
|
joined_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
left_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
left_at: Mapped[datetime | None] = mapped_column(DateTime, 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)
|
||||||
|
|
@ -255,7 +226,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(UtcDateTime, nullable=False)
|
fetched_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class CommandHistory(Base):
|
class CommandHistory(Base):
|
||||||
|
|
@ -277,5 +248,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(
|
||||||
UtcDateTime, default=now_utc, nullable=False
|
DateTime, default=now_utc, nullable=False
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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) - timedelta(seconds=stale_seconds)
|
cutoff = datetime.now(UTC).replace(tzinfo=None) - 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] = {}
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,9 @@ 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)
|
||||||
user.password_changed_at = now_utc()
|
# Strip tz so the marker matches what a subsequent DB read returns
|
||||||
|
# (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
|
||||||
|
|
|
||||||
|
|
@ -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) - timedelta(seconds=stale_seconds)
|
cutoff = datetime.now(UTC).replace(tzinfo=None) - 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) - timedelta(
|
recent_cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(
|
||||||
days=current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30)
|
days=current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
return datetime.now(UTC)
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
def poll_once() -> None:
|
def poll_once() -> None:
|
||||||
|
|
|
||||||
|
|
@ -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) - timedelta(minutes=5)
|
stale = datetime.now(UTC).replace(tzinfo=None) - 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()
|
||||||
|
|
|
||||||
|
|
@ -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) - s.joined_at).total_seconds()
|
delta = (datetime.now(UTC).replace(tzinfo=None) - 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) - timedelta(days=45)
|
long_ago = datetime.now(UTC).replace(tzinfo=None) - 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) - timedelta(hours=2)
|
way_back = datetime.now(UTC).replace(tzinfo=None) - 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),
|
fetched_at=datetime.now(UTC).replace(tzinfo=None),
|
||||||
))
|
))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
live_state_poller, "query_status",
|
live_state_poller, "query_status",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
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
|
||||||
|
|
@ -11,7 +8,6 @@ from l4d2web.models import (
|
||||||
ServerPlayerSession,
|
ServerPlayerSession,
|
||||||
SteamUserProfile,
|
SteamUserProfile,
|
||||||
User,
|
User,
|
||||||
UtcDateTime,
|
|
||||||
now_utc as now_utc_aware,
|
now_utc as now_utc_aware,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -44,7 +40,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)
|
before = datetime.now(UTC).replace(tzinfo=None)
|
||||||
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:
|
||||||
|
|
@ -112,15 +108,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
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)
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
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"],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue