diff --git a/Dockerfile b/Dockerfile index a9526a9..af55af5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,21 @@ FROM python:3.11-slim 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 && \ - apt-get install -y --no-install-recommends curl && \ + apt-get install -y --no-install-recommends curl tzdata && \ 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 minimateplus ./minimateplus COPY micromate ./micromate diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 6635f06..2f57f63 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -348,9 +348,11 @@ def render_event_report_pdf(rd: ReportData) -> bytes: # Page footer (common to both layouts) — Created date + event id. # Pushed to the very page bottom so it doesn't collide with the # 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( 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", ) fig.text( @@ -419,31 +421,50 @@ def _render_histogram_layout(fig, rd: ReportData) -> None: _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]: - """Convert a ISO-8601 timestamp like '2026-05-16T22:30:37' to BW's - display format '22:30:37 May 16, 2026'. Returns input unchanged if - it doesn't look like ISO.""" + """Convert an ISO-8601 timestamp to BW's display format + '22:30:37 May 16, 2026'. UTC inputs (with Z suffix) are + 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: return iso try: - import datetime as _dt - dt = _dt.datetime.fromisoformat(iso.replace("Z", "+00:00")) - return dt.strftime("%H:%M:%S %B %d, %Y").replace(" 0", " ") + return _to_display_local(iso).strftime("%H:%M:%S %B %d, %Y").replace(" 0", " ") except Exception: return iso 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 and Time rows are presented separately. Returns (None, None) - if the input isn't a valid ISO datetime.""" + Date and Time rows are presented separately. UTC inputs are + converted to local time first. Returns (None, None) on parse failure.""" if not iso: return (None, None) try: - import datetime as _dt - dt = _dt.datetime.fromisoformat(iso.replace("Z", "+00:00")) - # BW format: "May 27 /26" (3-letter month + 2-digit year) + dt = _to_display_local(iso) + # BW format: 'May 27 /26' (3-letter month + 2-digit year) date_str = dt.strftime("%b %d /%y").replace(" 0", " ") time_str = dt.strftime("%H:%M:%S") return (date_str, time_str)