mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 23:32:55 +08:00
113 lines
4.1 KiB
Python
113 lines
4.1 KiB
Python
"""
|
|
Time / time-zone helpers for serializing datetimes to the frontend.
|
|
|
|
Background
|
|
----------
|
|
Most ``qd_*`` tables use ``TIMESTAMP WITHOUT TIME ZONE`` columns. Our PostgreSQL
|
|
pool sets ``options="-c timezone=UTC"`` (see ``db_postgres.py``), so ``NOW()``
|
|
and driver round-trips store a *naive* **UTC wall-clock** value. When the
|
|
backend serializes that ``datetime`` with ``.isoformat()`` the result has **no
|
|
time zone suffix** (e.g. ``"2026-05-08T19:36:00"``).
|
|
|
|
The frontend uses ``new Date(text)`` to parse it; modern browsers interpret a
|
|
naive ISO string as the *browser's local time*, which yields wrong values for
|
|
any user whose browser time zone differs from the server's.
|
|
|
|
To fix this we always serialize timestamps as **UTC ISO 8601 with a ``Z``
|
|
suffix**. The browser then renders them in whatever locale the user is in,
|
|
without any further work on the frontend.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Optional
|
|
|
|
try:
|
|
from zoneinfo import ZoneInfo # py>=3.9
|
|
except Exception: # pragma: no cover - fallback for very old runtimes
|
|
ZoneInfo = None # type: ignore[misc,assignment]
|
|
|
|
|
|
def _db_naive_tzinfo() -> timezone:
|
|
"""Timezone for naive ``datetime`` values read from PostgreSQL.
|
|
|
|
The connection pool pins the session to UTC. Naive timestamps are UTC wall
|
|
clock — **not** the backend container's ``TZ`` (e.g. Asia/Shanghai).
|
|
"""
|
|
override = (os.getenv("DB_NAIVE_TIMESTAMP_TZ") or "UTC").strip() or "UTC"
|
|
if override.upper() in ("UTC", "GMT", "ETC/UTC", "ETC/GMT"):
|
|
return timezone.utc
|
|
if ZoneInfo is not None:
|
|
try:
|
|
return ZoneInfo(override) # type: ignore[return-value]
|
|
except Exception:
|
|
pass
|
|
return timezone.utc
|
|
|
|
|
|
def to_utc_iso(value: Any) -> Optional[str]:
|
|
"""Convert a value to a UTC ISO 8601 string with a ``Z`` suffix.
|
|
|
|
Accepts ``datetime``, ISO strings, numeric epoch seconds, or ``None``.
|
|
Returns ``None`` for falsy inputs that aren't valid timestamps.
|
|
|
|
Rules
|
|
-----
|
|
* Aware ``datetime`` → converted to UTC.
|
|
* Naive ``datetime`` → assumed to be UTC wall clock from our PG session
|
|
(``options=-c timezone=UTC``), then converted/emitted as UTC ``Z``.
|
|
* Numeric input → treated as epoch seconds (or milliseconds when too large).
|
|
* String input that parses as ISO 8601 → re-emitted in UTC. If the string
|
|
has no time-zone designator we treat it as server local time.
|
|
* Anything else → ``None`` (the route can decide to fall back to ``str()``).
|
|
"""
|
|
if value is None or value == "":
|
|
return None
|
|
|
|
dt: Optional[datetime] = None
|
|
|
|
if isinstance(value, datetime):
|
|
dt = value
|
|
elif isinstance(value, (int, float)):
|
|
ts = float(value)
|
|
# Heuristic: > 1e12 is milliseconds.
|
|
if ts > 1e12:
|
|
ts /= 1000.0
|
|
try:
|
|
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
except Exception:
|
|
return None
|
|
elif isinstance(value, str):
|
|
s = value.strip()
|
|
if not s:
|
|
return None
|
|
try:
|
|
# Support trailing "Z" (Python <3.11 does not accept it directly).
|
|
normalized = s.replace("Z", "+00:00") if s.endswith("Z") else s
|
|
# ``fromisoformat`` accepts both ``T`` and space separators since
|
|
# Python 3.11; on 3.9/3.10 it tolerates space too but not trailing
|
|
# microsecond rounding edge cases. Replace space defensively.
|
|
if " " in normalized and "T" not in normalized:
|
|
normalized = normalized.replace(" ", "T", 1)
|
|
dt = datetime.fromisoformat(normalized)
|
|
except Exception:
|
|
return None
|
|
else:
|
|
return None
|
|
|
|
if dt is None:
|
|
return None
|
|
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=_db_naive_tzinfo())
|
|
dt_utc = dt.astimezone(timezone.utc)
|
|
# Always emit with trailing Z and second-precision (drop microseconds for
|
|
# smaller, cleaner payloads). ISO 8601 with Z is unambiguous for all
|
|
# browsers.
|
|
return dt_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
__all__ = ["to_utc_iso"]
|