fix(db): bind datetimes, not strings, when filtering timestamptz columns
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 26s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 26s
Closes #1727 and #2257 — the same bug in two services written months apart. AppLog.created_at is `timestamp with time zone`. asyncpg binds a Python str as VARCHAR and Postgres has no `timestamptz >= text` operator, so both of these raised when Postgres planned the query: notifications.check_due_tasks AppLog.created_at >= today.isoformat() logging.get_logs AppLog.created_at >= <raw request.args str> #1727 was the worse of the two because a per-user `except Exception` swallowed it: reminder emails silently never sent, and the only outward trace was an hourly traceback in the Postgres log. It has been open since 2026-07-19 with the diagnosis written and the fix never applied. #2257 has no swallowing handler, so it merely breaks the admin log viewer's date filters outright. notifications: `utc_day_start(day)` returns midnight UTC as an AWARE datetime. Deliberately not the bare `date` the original diagnosis suggested — comparing timestamptz to date does work via an implicit cast, but Postgres resolves that cast in the SESSION's TimeZone, so the dedup window would drift with a server setting nobody remembers is load-bearing. logging: `parse_filter_datetime()` converts the query-string value to an aware UTC datetime; unparseable input returns None so the filter is skipped rather than 500ing the viewer. It also fixes a bug the naive fix would have introduced — `date_to=2026-07-30` parses to midnight, so `<=` would exclude the entire day the user asked for. Date-only upper bounds now run to 23:59:59.999999, while a value carrying an explicit time is left as given. The guard is the point. This class is invisible to ordinary testing: the failure happens when Postgres plans the query, not when Python builds it, so no unit test that doesn't execute SQL can see it. tests/test_timestamp_filters.py fails CI on two shapes — 1. a local bound to .isoformat() compared against a *_at column (#1727) 2. a str-ANNOTATED PARAMETER compared against a *_at column (#2257) Shape 2 is the one that matters. Nothing in logging.py looks date-ish, so a guard built only from #1727's shape finds nothing there — which is exactly how the second instance survived. Verified by replaying both checks against the pre-fix files out of git: shape 1 catches `today_str`, shape 2 catches `date_from`/`date_to`, and the current tree is clean. Found by grepping for siblings after fixing #1727 — the third instance today of "the second place nobody checked", after #2245. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -89,6 +89,38 @@ async def log_error(
|
||||
await session.commit()
|
||||
|
||||
|
||||
def parse_filter_datetime(value: str | None, *, end_of_day: bool = False) -> datetime | None:
|
||||
"""An ISO date/datetime from a query string as an AWARE UTC datetime.
|
||||
|
||||
The admin log filters arrive as raw `request.args` strings and used to be
|
||||
compared straight against `AppLog.created_at`. asyncpg binds a str as
|
||||
VARCHAR and Postgres has no `timestamptz >= text` operator, so supplying
|
||||
either date filter raised — the same defect as #1727 in notifications, in a
|
||||
second place (#2254).
|
||||
|
||||
Returns None for anything unparseable: a malformed filter should narrow
|
||||
nothing rather than 500 the log viewer.
|
||||
|
||||
`end_of_day` matters for the upper bound. A bare "2026-07-30" parses to
|
||||
midnight, so `created_at <= that` would exclude the whole of the day the
|
||||
user asked for — the one day they most likely wanted. With this flag a
|
||||
date-only value is pushed to the last microsecond of that day; a value that
|
||||
already carries a time is left exactly as given.
|
||||
"""
|
||||
raw = (value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if end_of_day and len(raw) == 10: # date-only, no time component
|
||||
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
async def get_logs(
|
||||
category: str | None = None,
|
||||
user_id: int | None = None,
|
||||
@@ -118,12 +150,14 @@ async def get_logs(
|
||||
)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
if date_from:
|
||||
query = query.where(AppLog.created_at >= date_from)
|
||||
count_query = count_query.where(AppLog.created_at >= date_from)
|
||||
if date_to:
|
||||
query = query.where(AppLog.created_at <= date_to)
|
||||
count_query = count_query.where(AppLog.created_at <= date_to)
|
||||
start = parse_filter_datetime(date_from)
|
||||
end = parse_filter_datetime(date_to, end_of_day=True)
|
||||
if start:
|
||||
query = query.where(AppLog.created_at >= start)
|
||||
count_query = count_query.where(AppLog.created_at >= start)
|
||||
if end:
|
||||
query = query.where(AppLog.created_at <= end)
|
||||
count_query = count_query.where(AppLog.created_at <= end)
|
||||
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user