diff --git a/elementary/monitor/alerts/alert.py b/elementary/monitor/alerts/alert.py index eb1af224f..a31dd7a33 100644 --- a/elementary/monitor/alerts/alert.py +++ b/elementary/monitor/alerts/alert.py @@ -58,7 +58,11 @@ def __init__( self.detected_at = None if detected_at is not None: try: - self.detected_at_utc = detected_at + # detected_at is stored in UTC, so a naive datetime must be + # interpreted as UTC rather than as the machine's local time. + if detected_at.tzinfo is None: + detected_at = detected_at.replace(tzinfo=tz.tzutc()) + self.detected_at_utc = detected_at.astimezone(tz.tzutc()) self.detected_at = detected_at.astimezone( tz.gettz(timezone) if timezone else tz.tzlocal() ) diff --git a/tests/unit/monitor/alerts/test_alert_models.py b/tests/unit/monitor/alerts/test_alert_models.py index 13c5c5673..79de2dfa6 100644 --- a/tests/unit/monitor/alerts/test_alert_models.py +++ b/tests/unit/monitor/alerts/test_alert_models.py @@ -1,4 +1,8 @@ +import time +from datetime import datetime + import pytest +from dateutil import tz from elementary.monitor.alerts.alert import AlertModel from elementary.monitor.alerts.model_alert import ModelAlertModel @@ -6,6 +10,15 @@ from elementary.monitor.alerts.test_alert import TestAlertModel +@pytest.fixture +def tokyo_local_timezone(monkeypatch): + monkeypatch.setenv("TZ", "Asia/Tokyo") + time.tzset() + yield + monkeypatch.undo() + time.tzset() + + def _make_test_alert( test_sub_type: str = "generic", test_short_name: str = "my_test" ) -> TestAlertModel: @@ -95,3 +108,75 @@ def test_asset_type_is_source(self) -> None: def test_concise_name_is_source_dot_identifier(self) -> None: alert = _make_source_freshness_alert(source_name="raw", identifier="orders") assert alert.concise_name == "raw.orders" + + +class TestAlertModelDetectedAtTimezone: + """detected_at is stored in UTC (naive) in the alerts table and must be + interpreted as UTC, not as the machine's local time.""" + + def test_naive_detected_at_is_converted_to_given_timezone( + self, tokyo_local_timezone + ) -> None: + alert = AlertModel( + id="id", + alert_class_id="acid", + detected_at=datetime(2026, 7, 22, 9, 8, 22), + timezone="Asia/Tokyo", + ) + assert alert.detected_at == datetime( + 2026, 7, 22, 18, 8, 22, tzinfo=tz.gettz("Asia/Tokyo") + ) + assert alert.detected_at_str == "2026-07-22 18:08:22 JST" + + def test_naive_detected_at_is_converted_to_local_timezone_by_default( + self, tokyo_local_timezone + ) -> None: + alert = AlertModel( + id="id", + alert_class_id="acid", + detected_at=datetime(2026, 7, 22, 9, 8, 22), + ) + assert alert.detected_at_str == "2026-07-22 18:08:22 JST" + + def test_naive_detected_at_utc_is_utc_aware(self, tokyo_local_timezone) -> None: + alert = AlertModel( + id="id", + alert_class_id="acid", + detected_at=datetime(2026, 7, 22, 9, 8, 22), + timezone="Asia/Tokyo", + ) + assert alert.detected_at_utc == datetime( + 2026, 7, 22, 9, 8, 22, tzinfo=tz.tzutc() + ) + + def test_aware_detected_at_keeps_working(self, tokyo_local_timezone) -> None: + alert = AlertModel( + id="id", + alert_class_id="acid", + detected_at=datetime(2026, 7, 22, 9, 8, 22, tzinfo=tz.tzutc()), + timezone="Asia/Tokyo", + ) + assert alert.detected_at_str == "2026-07-22 18:08:22 JST" + + def test_source_freshness_result_description_uses_consistent_timezone( + self, tokyo_local_timezone + ) -> None: + alert = SourceFreshnessAlertModel( + id="id", + source_name="my_source", + identifier="my_table", + original_status="fail", + path="models/src.yml", + error=None, + alert_class_id="acid", + source_freshness_execution_id="sfeid", + detected_at=datetime(2026, 7, 22, 9, 8, 22), + max_loaded_at=datetime(2026, 7, 21, 8, 55, 10), + max_loaded_at_time_ago_in_s=87101.8, + timezone="Asia/Tokyo", + ) + assert alert.result_description == ( + "When the test ran at 2026-07-22 18:08:22 JST, " + "the most recent record found in the table was 1 day 0h 11m 41s earlier " + "(2026-07-21 17:55:10 JST)." + )