From a127aa07dfb6743388d3214fa701add6c6b321a7 Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 10 Mar 2026 09:29:40 +0300 Subject: [PATCH] fix(dashboards): normalize naive/aware datetimes in resource task ordering --- .../__tests__/test_resource_service.py | 69 ++++++++++++++++++- backend/src/services/resource_service.py | 27 ++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/backend/src/services/__tests__/test_resource_service.py b/backend/src/services/__tests__/test_resource_service.py index d8c5c761..c1d73a11 100644 --- a/backend/src/services/__tests__/test_resource_service.py +++ b/backend/src/services/__tests__/test_resource_service.py @@ -9,7 +9,7 @@ import pytest from unittest.mock import MagicMock, patch, AsyncMock -from datetime import datetime +from datetime import datetime, timezone # [DEF:test_get_dashboards_with_status:Function] @@ -269,4 +269,71 @@ def test_get_last_task_for_resource_no_match(): # [/DEF:test_get_last_task_for_resource_no_match:Function] +# [DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function] +# @TEST: get_dashboards_with_status handles mixed naive/aware datetimes without comparison errors. +# @PRE: Task list includes both timezone-aware and timezone-naive timestamps. +# @POST: Latest task is selected deterministically and no exception is raised. +@pytest.mark.asyncio +async def test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes(): + with patch("src.services.resource_service.SupersetClient") as mock_client, \ + patch("src.services.resource_service.GitService"): + + from src.services.resource_service import ResourceService + + service = ResourceService() + mock_client.return_value.get_dashboards_summary.return_value = [ + {"id": 1, "title": "Dashboard 1", "slug": "dash-1"} + ] + + task_naive = MagicMock() + task_naive.id = "task-naive" + task_naive.plugin_id = "llm_dashboard_validation" + task_naive.status = "SUCCESS" + task_naive.params = {"dashboard_id": "1", "environment_id": "prod"} + task_naive.started_at = datetime(2024, 1, 1, 10, 0, 0) + + task_aware = MagicMock() + task_aware.id = "task-aware" + task_aware.plugin_id = "llm_dashboard_validation" + task_aware.status = "SUCCESS" + task_aware.params = {"dashboard_id": "1", "environment_id": "prod"} + task_aware.started_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + env = MagicMock() + env.id = "prod" + + result = await service.get_dashboards_with_status(env, [task_naive, task_aware]) + + assert result[0]["last_task"]["task_id"] == "task-aware" +# [/DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function] + + +# [DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function] +# @TEST: _get_last_task_for_resource handles mixed naive/aware created_at values. +# @PRE: Matching tasks include naive and aware created_at timestamps. +# @POST: Latest task is returned without raising datetime comparison errors. +def test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at(): + from src.services.resource_service import ResourceService + + service = ResourceService() + + task_naive = MagicMock() + task_naive.id = "task-old" + task_naive.status = "SUCCESS" + task_naive.params = {"resource_id": "dashboard-1"} + task_naive.created_at = datetime(2024, 1, 1, 10, 0, 0) + + task_aware = MagicMock() + task_aware.id = "task-new" + task_aware.status = "RUNNING" + task_aware.params = {"resource_id": "dashboard-1"} + task_aware.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + result = service._get_last_task_for_resource("dashboard-1", [task_naive, task_aware]) + + assert result is not None + assert result["task_id"] == "task-new" +# [/DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function] + + # [/DEF:backend.src.services.__tests__.test_resource_service:Module] diff --git a/backend/src/services/resource_service.py b/backend/src/services/resource_service.py index 26068383..eabc2469 100644 --- a/backend/src/services/resource_service.py +++ b/backend/src/services/resource_service.py @@ -10,7 +10,7 @@ # [SECTION: IMPORTS] from typing import List, Dict, Optional, Any -from datetime import datetime +from datetime import datetime, timezone from ..core.superset_client import SupersetClient from ..core.task_manager.models import Task from ..services.git_service import GitService @@ -179,12 +179,12 @@ class ResourceService: return None def _task_time(task_obj: Any) -> datetime: - return ( + raw_time = ( getattr(task_obj, "started_at", None) or getattr(task_obj, "finished_at", None) or getattr(task_obj, "created_at", None) - or datetime.min ) + return self._normalize_datetime_for_compare(raw_time) last_task = max(matched_tasks, key=_task_time) raw_result = getattr(last_task, "result", None) @@ -229,6 +229,20 @@ class ResourceService: return status_text return "UNKNOWN" # [/DEF:_normalize_validation_status:Function] + + # [DEF:_normalize_datetime_for_compare:Function] + # @PURPOSE: Normalize datetime values to UTC-aware values for safe comparisons. + # @PRE: value may be datetime or any scalar. + # @POST: Returns UTC-aware datetime; non-datetime values map to minimal UTC datetime. + # @PARAM: value (Any) - Candidate datetime-like value. + # @RETURN: datetime - UTC-aware comparable datetime. + def _normalize_datetime_for_compare(self, value: Any) -> datetime: + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + return datetime.min.replace(tzinfo=timezone.utc) + # [/DEF:_normalize_datetime_for_compare:Function] # [DEF:get_datasets_with_status:Function] # @PURPOSE: Fetch datasets from environment with mapping progress and last task status @@ -391,8 +405,11 @@ class ResourceService: if not resource_tasks: return None - # Get most recent task - last_task = max(resource_tasks, key=lambda t: t.created_at) + # Get most recent task with timezone-safe comparison. + last_task = max( + resource_tasks, + key=lambda t: self._normalize_datetime_for_compare(getattr(t, "created_at", None)), + ) return { 'task_id': str(last_task.id),