tz: server-wide display timezone via TZ env var (default EST/EDT)

User-reported issue: server logs were timestamped in UTC ("05:36:20"
when local was ~01:36 EDT), and the PDF report's "Created" footer
similarly showed raw UTC.  Inconsistent with the modal which already
converts to browser local via toLocaleString.

Solution: standard Linux TZ env var.  Set once in the container, and:
  - Python's datetime.now() uses local
  - Logging module's timestamps use local
  - matplotlib renderers + report_pdf formatters use local
  - astimezone() conversions resolve to the configured TZ

DB columns stay UTC (created_at uses SQLite's strftime('%Y-...Z', 'now')
which is always UTC, regardless of TZ env var — proper "store UTC,
display local" pattern).

Changes:
  - Dockerfile: install tzdata (python:3.11-slim omits the timezone
    database), set default TZ=America/New_York
  - sfm/report_pdf.py: _fmt_iso_to_bw and _split_iso_to_date_time now
    convert UTC inputs (Z-suffixed) to local via astimezone(); naïve
    inputs (BW recorded-at, already unit-local) returned as-is.
    New _to_display_local helper centralizes the logic.
  - "Created" line in the PDF page footer now uses the converted
    timestamp.

Override per-deployment via the TZ env var in docker-compose
(separate commit on terra-view side).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 05:41:10 +00:00
parent 53c05d93e2
commit 6381dcb312
2 changed files with 46 additions and 14 deletions
+12 -1
View File
@@ -2,10 +2,21 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# tzdata is required for the TZ env var to take effect (python:slim
# omits the timezone database). Without it, datetime.now() / logging
# / matplotlib all stay in UTC regardless of TZ. Default zone gets
# set further down via ENV; users override per-deployment via the
# `TZ` env var in docker-compose.
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \ apt-get install -y --no-install-recommends curl tzdata && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Default display timezone — applied to server logs, datetime.now(),
# matplotlib rendered timestamps, and any naïve-vs-aware datetime
# conversions in the PDF renderer. Override via TZ env var in
# docker-compose; storage in the DB is always UTC regardless.
ENV TZ=America/New_York
COPY pyproject.toml requirements.txt ./ COPY pyproject.toml requirements.txt ./
COPY minimateplus ./minimateplus COPY minimateplus ./minimateplus
COPY micromate ./micromate COPY micromate ./micromate
+34 -13
View File
@@ -348,9 +348,11 @@ def render_event_report_pdf(rd: ReportData) -> bytes:
# Page footer (common to both layouts) — Created date + event id. # Page footer (common to both layouts) — Created date + event id.
# Pushed to the very page bottom so it doesn't collide with the # Pushed to the very page bottom so it doesn't collide with the
# waveform footer scale / trigger legend lines just above. # waveform footer scale / trigger legend lines just above.
# Convert UTC server_received_at to local for display.
created_local = _fmt_iso_to_bw(rd.server_received_at) if rd.server_received_at else ""
fig.text( fig.text(
0.07, 0.005, 0.07, 0.005,
f"Created: {rd.server_received_at or ''} • seismo-relay", f"Created: {created_local} • seismo-relay",
fontsize=6, color="#888", ha="left", fontsize=6, color="#888", ha="left",
) )
fig.text( fig.text(
@@ -419,31 +421,50 @@ def _render_histogram_layout(fig, rd: ReportData) -> None:
_draw_histogram_subplot(fig, gs[3], rd) _draw_histogram_subplot(fig, gs[3], rd)
def _to_display_local(iso: str):
"""Parse an ISO timestamp and return a datetime in the system's local
timezone (set by the TZ env var, default America/New_York via the
Dockerfile).
Behaviour:
- "...Z" or "...+HH:MM" suffix → tz-aware UTC → converted to local
- Naïve "YYYY-MM-DDTHH:MM:SS" (no tz) → returned as-is. This
matches the convention used elsewhere in seismo-relay: BW's
recorded-at timestamps are naïve and ALREADY in the unit's
local clock; we don't second-guess them.
"""
import datetime as _dt
dt = _dt.datetime.fromisoformat(iso.replace("Z", "+00:00"))
if dt.tzinfo is not None:
# Convert from UTC (or other tz) → local per the TZ env var.
# astimezone() without arg uses the system timezone.
dt = dt.astimezone()
return dt
def _fmt_iso_to_bw(iso: Optional[str]) -> Optional[str]: def _fmt_iso_to_bw(iso: Optional[str]) -> Optional[str]:
"""Convert a ISO-8601 timestamp like '2026-05-16T22:30:37' to BW's """Convert an ISO-8601 timestamp to BW's display format
display format '22:30:37 May 16, 2026'. Returns input unchanged if '22:30:37 May 16, 2026'. UTC inputs (with Z suffix) are
it doesn't look like ISO.""" converted to the system's local timezone first; naïve inputs
are formatted as-is. Returns input unchanged on parse failure."""
if not iso or "T" not in iso: if not iso or "T" not in iso:
return iso return iso
try: try:
import datetime as _dt return _to_display_local(iso).strftime("%H:%M:%S %B %d, %Y").replace(" 0", " ")
dt = _dt.datetime.fromisoformat(iso.replace("Z", "+00:00"))
return dt.strftime("%H:%M:%S %B %d, %Y").replace(" 0", " ")
except Exception: except Exception:
return iso return iso
def _split_iso_to_date_time(iso: Optional[str]) -> tuple[Optional[str], Optional[str]]: def _split_iso_to_date_time(iso: Optional[str]) -> tuple[Optional[str], Optional[str]]:
"""Split an ISO timestamp into BW-formatted ("May 27 /26", "06:06:14") """Split an ISO timestamp into BW-formatted ('May 27 /26', '06:06:14')
date+time strings. Used for the histogram stats table where the date+time strings. Used for the histogram stats table where the
Date and Time rows are presented separately. Returns (None, None) Date and Time rows are presented separately. UTC inputs are
if the input isn't a valid ISO datetime.""" converted to local time first. Returns (None, None) on parse failure."""
if not iso: if not iso:
return (None, None) return (None, None)
try: try:
import datetime as _dt dt = _to_display_local(iso)
dt = _dt.datetime.fromisoformat(iso.replace("Z", "+00:00")) # BW format: 'May 27 /26' (3-letter month + 2-digit year)
# BW format: "May 27 /26" (3-letter month + 2-digit year)
date_str = dt.strftime("%b %d /%y").replace(" 0", " ") date_str = dt.strftime("%b %d /%y").replace(" 0", " ")
time_str = dt.strftime("%H:%M:%S") time_str = dt.strftime("%H:%M:%S")
return (date_str, time_str) return (date_str, time_str)