Files
QuantDinger/backend_api_python/app/utils/timeutil.py
T
Dinger b9ba06329f v3.0.14
Signed-off-by: Dinger <quantdinger@gmail.com>
2026-05-25 19:25:56 +08:00

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"]