diff --git a/elementary/monitor/alerts/alert.py b/elementary/monitor/alerts/alert.py index eb1af224f..10a2aeeac 100644 --- a/elementary/monitor/alerts/alert.py +++ b/elementary/monitor/alerts/alert.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone as dt_timezone from typing import Dict, List, Optional from dateutil import tz @@ -58,8 +58,16 @@ def __init__( self.detected_at = None if detected_at is not None: try: - self.detected_at_utc = detected_at - self.detected_at = detected_at.astimezone( + # Timestamps from the DB are stored as UTC but arrive as naive + # datetimes. Python's astimezone() interprets a naive datetime + # as *local* time, so we must attach UTC tzinfo first. + detected_at_utc_aware = ( + detected_at.replace(tzinfo=dt_timezone.utc) + if detected_at.tzinfo is None + else detected_at + ) + self.detected_at_utc = detected_at_utc_aware + self.detected_at = detected_at_utc_aware.astimezone( tz.gettz(timezone) if timezone else tz.tzlocal() ) except Exception: diff --git a/elementary/monitor/data_monitoring/schema.py b/elementary/monitor/data_monitoring/schema.py index 25bc536fd..ca09ec310 100644 --- a/elementary/monitor/data_monitoring/schema.py +++ b/elementary/monitor/data_monitoring/schema.py @@ -22,6 +22,7 @@ class Status(str, Enum): ERROR = "error" RUNTIME_ERROR = "runtime error" PARTIAL_SUCCESS = "partial success" + REUSED = "reused" class ResourceType(str, Enum): diff --git a/tests/unit/alerts/test_alert_detected_at_timezone.py b/tests/unit/alerts/test_alert_detected_at_timezone.py new file mode 100644 index 000000000..dd510f93d --- /dev/null +++ b/tests/unit/alerts/test_alert_detected_at_timezone.py @@ -0,0 +1,58 @@ +""" +Regression tests for issue #2304: +AlertModel must treat naive detected_at values as UTC, not local time. +""" +from datetime import datetime, timezone as dt_timezone + +import pytest + +from elementary.monitor.alerts.alert import AlertModel + + +def _make_alert(detected_at, timezone=None): + return AlertModel( + id="test", + alert_class_id="cls", + detected_at=detected_at, + timezone=timezone, + ) + + +def test_naive_detected_at_treated_as_utc_not_local(): + """A naive datetime must be interpreted as UTC, not as local wall-clock time. + + Regression: alert.detected_at_utc must equal the naive input with UTC tzinfo + attached, regardless of the process timezone. + """ + naive_utc = datetime(2026, 7, 22, 9, 8, 22) + alert = _make_alert(naive_utc, timezone="Asia/Tokyo") + + expected_utc = naive_utc.replace(tzinfo=dt_timezone.utc) + assert alert.detected_at_utc == expected_utc, ( + "detected_at_utc must be the naive input with UTC tzinfo, " + f"got {alert.detected_at_utc!r}" + ) + + # After conversion to JST (+09:00) the hour must be 18 (9 + 9) + assert alert.detected_at is not None + assert alert.detected_at.hour == 18, ( + "09:08:22 UTC converted to JST (+09:00) must be 18:08:22, " + f"got {alert.detected_at}" + ) + + +def test_aware_detected_at_is_not_re_interpreted(): + """An already-aware datetime must be used as-is (no double-conversion).""" + aware_utc = datetime(2026, 7, 22, 9, 8, 22, tzinfo=dt_timezone.utc) + alert = _make_alert(aware_utc, timezone="Asia/Tokyo") + + assert alert.detected_at_utc == aware_utc + assert alert.detected_at is not None + assert alert.detected_at.hour == 18 + + +def test_none_detected_at_leaves_fields_none(): + alert = _make_alert(None) + assert alert.detected_at is None + assert alert.detected_at_utc is None + assert alert.detected_at_str == "N/A" diff --git a/tests/unit/monitor/api/alerts/test_alert_filters.py b/tests/unit/monitor/api/alerts/test_alert_filters.py index 5ab032dd5..8fa89dd21 100644 --- a/tests/unit/monitor/api/alerts/test_alert_filters.py +++ b/tests/unit/monitor/api/alerts/test_alert_filters.py @@ -1004,3 +1004,49 @@ def test_multi_filters(): "test_alert_1", "test_alert_2", ] + + +def test_reused_status_does_not_crash_filter_alerts(): + """Regression test for dbt State 'reused' status crashing filter_alerts. + + dbt State (v1.11+) introduces a 'reused' run status for models skipped + because their state hasn't changed. When such an alert reached + filter_alerts the call to Status('reused') raised ValueError because + 'reused' was not a member of the Status enum. + """ + reused_alert = PendingAlertSchema( + id="reused_model_alert", + alert_class_id="elementary.model_id_reused", + type=AlertTypes.MODEL, + detected_at=datetime(2022, 10, 10, 10, 0, 0), + created_at=datetime(2022, 10, 10, 10, 0, 0), + updated_at=datetime(2022, 10, 10, 10, 0, 0), + status=AlertStatus.PENDING, + data=ModelAlertDataSchema( + id="reused_1", + alert_class_id="elementary.model_id_reused", + model_unique_id="elementary.model_id_reused", + alias="reused_model", + path="my/path", + original_path="", + materialization="table", + message="", + full_refresh=False, + detected_at=datetime(2022, 10, 10, 10, 0, 0), + tags=[], + model_meta={}, + status="reused", + database_name="test_db", + schema_name="test_schema", + resource_type=ResourceType.MODEL, + ), + ) + + # Must not raise ValueError("'reused' is not a valid Status"). + # The alert is correctly filtered out by the default status filter (which + # only surfaces FAIL / ERROR / RUNTIME_ERROR / WARN). + result = filter_alerts([reused_alert], FiltersSchema()) + assert len(result) == 0 + + # Status enum must include REUSED + assert Status("reused") is Status.REUSED