diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 482187e..a018e7d 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -528,9 +528,9 @@ def main(argv: Optional[List[str]] = None) -> int: dynamic = Config._app.period_dynamic if Config._app else False if precision == "datetime": - from datetime import datetime as dt_mod - - now = dt_mod.now().isoformat(timespec="seconds") + # Сохраняем aware UTC (#58): следующий запуск вычисляет из этой + # метки aware cutoff для дедупликации. + now = datetime.now(timezone.utc).isoformat(timespec="seconds") from_str = now to_str = now else: diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index 04bb941..427efb0 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple, Union import requests @@ -150,20 +150,28 @@ def _parse_datetime(value: Any) -> Optional[datetime]: Accepts datetime objects, ISO strings (with or without timezone), or None. Returns a timezone-aware datetime or None. + Naive datetimes are treated as UTC (#58). """ if value is None: return None if isinstance(value, datetime): - return value + return _ensure_aware_utc(value) if isinstance(value, str): try: dt = datetime.fromisoformat(value.replace("Z", "+00:00")) - return dt + return _ensure_aware_utc(dt) except (ValueError, TypeError): return None return None +def _ensure_aware_utc(dt: datetime) -> datetime: + """Возвращает aware datetime; naive трактуется как UTC (#58).""" + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt + + def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]: """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" all_issues: List[Issue] = [] @@ -277,20 +285,29 @@ def fetch_issues_with_spent_time( # Запись исключается, если BOTH created_on AND updated_on < dedup_before. # Записи без метаданных (created_on/updated_on == None) не фильтруются. if dedup_before is not None: + # Нормализуем cutoff к aware UTC (#58): naive cutoff трактуем как UTC, + # чтобы сравнение с нормализованными created_on/updated_on было корректным. + dedup_before = _ensure_aware_utc(dedup_before) filtered: list = [] - for entry in time_entries: - created = _parse_datetime(getattr(entry, "created_on", None)) - updated = _parse_datetime(getattr(entry, "updated_on", None)) + try: + for entry in time_entries: + created = _parse_datetime(getattr(entry, "created_on", None)) + updated = _parse_datetime(getattr(entry, "updated_on", None)) - if created is None and updated is None: - filtered.append(entry) - elif created is not None and updated is not None: - if created >= dedup_before and updated >= dedup_before: + if created is None and updated is None: filtered.append(entry) - elif created is not None and created >= dedup_before: - filtered.append(entry) - elif updated is not None and updated >= dedup_before: - filtered.append(entry) + elif created is not None and updated is not None: + if created >= dedup_before and updated >= dedup_before: + filtered.append(entry) + elif created is not None and created >= dedup_before: + filtered.append(entry) + elif updated is not None and updated >= dedup_before: + filtered.append(entry) + except TypeError as exc: + raise RedmineAPIError( + f"Failed to compare time entry dates with deduplication cutoff: {exc}", + original=exc, + ) from exc time_entries = filtered # Агрегируем часы по issue.id (и активности, если требуется) diff --git a/tests/test_cli.py b/tests/test_cli.py index b684cc2..95c6610 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -656,6 +656,49 @@ class TestCommitFlag: assert "T" in saved_from assert "T" in saved_to + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_with_precision_datetime_saves_aware_utc( + self, mock_save, mock_fetch, tmp_path + ): + """При precision=datetime --commit сохраняет last_used как aware UTC (#58).""" + from datetime import datetime, timezone + + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + + config_path = tmp_path / "config.yml" + config_path.write_text( + yaml.dump({"period": {"precision": "datetime", "dynamic": True}}) + ) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-30--2026-06-30", + "--commit", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + call_args = mock_save.call_args + assert call_args is not None + saved_from, saved_to = call_args.args[1], call_args.args[2] + for saved in (saved_from, saved_to): + parsed = datetime.fromisoformat(saved) + assert parsed.tzinfo is not None, f"{saved} must be timezone-aware" + assert parsed.utcoffset() == timezone.utc.utcoffset(None) + @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path): diff --git a/tests/test_client.py b/tests/test_client.py index 8ada219..f8c0b66 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -745,11 +745,6 @@ def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class): # -- #58: дедупликация при precision=datetime с naive created_on -- -@pytest.mark.xfail( - strict=True, - reason="bug #58: naive created_on vs aware dedup cutoff raises TypeError; " - "fix must normalize entry datetimes to aware (UTC)", -) @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch("redmine_reporter.client.Redmine") def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class): @@ -801,3 +796,90 @@ def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class): assert result is not None assert [r[0].id for r in result] == [2] + + +# -- #58: нормализация datetime к aware UTC -- + + +class TestParseDatetimeNormalization: + """_parse_datetime всегда возвращает aware datetime; naive трактуется как UTC.""" + + def test_naive_datetime_treated_as_utc(self): + from datetime import datetime, timezone + + from redmine_reporter.client import _parse_datetime + + result = _parse_datetime(datetime(2026, 7, 1, 10, 0, 0)) + assert result == datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc) + + def test_aware_datetime_kept(self): + from datetime import datetime, timedelta, timezone + + from redmine_reporter.client import _parse_datetime + + aware = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone(timedelta(hours=3))) + assert _parse_datetime(aware) is aware + + def test_naive_iso_string_treated_as_utc(self): + from datetime import timezone + + from redmine_reporter.client import _parse_datetime + + result = _parse_datetime("2026-07-01T10:00:00") + assert result is not None + assert result.tzinfo == timezone.utc + + def test_aware_iso_string_keeps_offset(self): + from datetime import timedelta + + from redmine_reporter.client import _parse_datetime + + result = _parse_datetime("2026-07-01T10:00:00+03:00") + assert result is not None + assert result.utcoffset() == timedelta(hours=3) + + def test_none_and_garbage_return_none(self): + from redmine_reporter.client import _parse_datetime + + assert _parse_datetime(None) is None + assert _parse_datetime("not a date") is None + assert _parse_datetime(123) is None + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_dedup_accepts_naive_cutoff(mock_redmine_class): + """Naive dedup_before трактуется как UTC — сравнение не падает (#58).""" + from datetime import datetime + + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine, user_id=123) + + naive_cutoff = datetime(2026, 7, 1, 12, 0, 0) # naive == UTC + + e_old = mock.MagicMock() + e_old.issue.id = 1 + e_old.hours = 2.0 + e_old.created_on = datetime(2026, 7, 1, 10, 0, 0) + e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0) + + e_new = mock.MagicMock() + e_new.issue.id = 2 + e_new.hours = 1.0 + e_new.created_on = datetime(2026, 7, 1, 14, 0, 0) + e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0) + + mock_redmine.time_entry.filter.return_value = [e_old, e_new] + + mock_issue1 = mock.MagicMock() + mock_issue1.id = 1 + mock_issue2 = mock.MagicMock() + mock_issue2.id = 2 + mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2] + + result = fetch_issues_with_spent_time( + "2026-07-01", "2026-07-01", dedup_before=naive_cutoff + ) + + assert result is not None + assert [r[0].id for r in result] == [2]