refactor: период через period.from вместо last_used; дедуп удалён
Удалены last_used, precision, dynamic и дедупликация по created_on/updated_on. Период задаётся одним полем period.from (env FROM_DATE), окно -- от этой даты до сегодня. После успешного --commit/--send начало окна сдвигается на конец отчёта; datetime-границы --date приводятся к полным дням. Схема конфига ломающая: старые ключи игнорируются, миграция описана в документации.
This commit is contained in:
@@ -605,256 +605,6 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
|
||||
assert len(result) == 250
|
||||
|
||||
|
||||
# -- #47: Дедупликация time entries по datetime --
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
|
||||
"""Записи с created_on/updated_on раньше dedup_before исключаются."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||
e1.updated_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e2 = mock.MagicMock()
|
||||
e2.issue.id = 2
|
||||
e2.hours = 1.0
|
||||
e2.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
e2.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1, e2]
|
||||
|
||||
mock_issue1 = mock.MagicMock()
|
||||
mock_issue1.id = 1
|
||||
mock_issue1.project = "P"
|
||||
mock_issue2 = mock.MagicMock()
|
||||
mock_issue2.id = 2
|
||||
mock_issue2.project = "P"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0][0].id == 2
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_filters_entries_with_old_created_even_if_updated_recently(
|
||||
mock_redmine_class,
|
||||
):
|
||||
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||
e1.updated_on = datetime(2026, 7, 1, 15, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1]
|
||||
mock_redmine.issue.filter.return_value = []
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
|
||||
"""Записи с created_on/updated_on >= dedup_before сохраняются."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
e1.updated_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1]
|
||||
|
||||
mock_issue1 = mock.MagicMock()
|
||||
mock_issue1.id = 1
|
||||
mock_issue1.project = "P"
|
||||
mock_issue1.subject = "T"
|
||||
mock_issue1.status = "New"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_entries_without_created_on_are_kept(mock_redmine_class):
|
||||
"""Записи без created_on/updated_on не фильтруются (сохраняются)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = None
|
||||
e1.updated_on = None
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1]
|
||||
|
||||
mock_issue1 = mock.MagicMock()
|
||||
mock_issue1.id = 1
|
||||
mock_issue1.project = "P"
|
||||
mock_issue1.subject = "T"
|
||||
mock_issue1.status = "New"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_handles_string_created_on(mock_redmine_class):
|
||||
"""created_on может быть строкой ISO — парсим корректно."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = "2026-07-01T10:00:00Z"
|
||||
e1.updated_on = "2026-07-01T10:00:00Z"
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1]
|
||||
mock_redmine.issue.filter.return_value = []
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_no_dedup_when_cutoff_is_none(mock_redmine_class):
|
||||
"""Без dedup_before фильтрация не применяется."""
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
e1 = mock.MagicMock()
|
||||
e1.issue.id = 1
|
||||
e1.hours = 2.0
|
||||
e1.created_on = None
|
||||
e1.updated_on = None
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e1]
|
||||
|
||||
mock_issue1 = mock.MagicMock()
|
||||
mock_issue1.id = 1
|
||||
mock_issue1.project = "P"
|
||||
mock_issue1.subject = "T"
|
||||
mock_issue1.status = "New"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||
|
||||
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=None)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
|
||||
"""Смешанные записи: старые исключаются, новые сохраняются, аки идут."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e_old = mock.MagicMock()
|
||||
e_old.issue.id = 10
|
||||
e_old.hours = 1.0
|
||||
e_old.created_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
|
||||
e_old.updated_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e_new = mock.MagicMock()
|
||||
e_new.issue.id = 20
|
||||
e_new.hours = 3.0
|
||||
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
e_akiy = mock.MagicMock()
|
||||
e_akiy.issue.id = 30
|
||||
e_akiy.hours = 5.0
|
||||
e_akiy.created_on = None
|
||||
e_akiy.updated_on = None
|
||||
|
||||
mock_redmine.time_entry.filter.return_value = [e_old, e_new, e_akiy]
|
||||
|
||||
mock_issue_new = mock.MagicMock()
|
||||
mock_issue_new.id = 20
|
||||
mock_issue_new.project = "P"
|
||||
mock_issue_new.subject = "T"
|
||||
mock_issue_new.status = "New"
|
||||
mock_issue_akiy = mock.MagicMock()
|
||||
mock_issue_akiy.id = 30
|
||||
mock_issue_akiy.project = "P"
|
||||
mock_issue_akiy.subject = "T2"
|
||||
mock_issue_akiy.status = "New"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy]
|
||||
|
||||
result = fetch_issues_with_spent_time(
|
||||
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 2
|
||||
ids = {r[0].id for r in result}
|
||||
assert ids == {20, 30}
|
||||
|
||||
|
||||
# -- Пагинация time entries (>100 записей) --
|
||||
|
||||
|
||||
@@ -904,149 +654,6 @@ def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
|
||||
assert hours_by_id[2] == 100.0
|
||||
|
||||
|
||||
# -- #58: дедупликация при precision=datetime с naive created_on --
|
||||
|
||||
|
||||
@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):
|
||||
"""python-redmine отдаёт naive created_on; cutoff aware (UTC) — фильтрация не падает.
|
||||
|
||||
Ожидаемое поведение после фикса #58: naive datetime трактуется как UTC,
|
||||
старые записи отсекаются, новые сохраняются.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.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) # naive, как из python-redmine
|
||||
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) # naive
|
||||
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_issue1.project = "P"
|
||||
mock_issue1.subject = "T"
|
||||
mock_issue1.status = "New"
|
||||
mock_issue2 = mock.MagicMock()
|
||||
mock_issue2.id = 2
|
||||
mock_issue2.project = "P"
|
||||
mock_issue2.subject = "T"
|
||||
mock_issue2.status = "New"
|
||||
# API возвращает обе задачи: issue 1 попадёт в результат, только если
|
||||
# её time entry не отсечён дедупликацией. Корректный фикс #58 отсекает
|
||||
# e_old (created_on/updated_on < cutoff) -> [2]; неправильный фикс
|
||||
# "погасить TypeError и оставить всё" даст [1, 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=dedup_cutoff
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
|
||||
# -- #61: предупреждения о молчаливой потере данных --
|
||||
|
||||
|
||||
@@ -1153,3 +760,48 @@ def test_load_time_entry_activities_returns_empty_on_failure(capsys):
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "activit" in captured.err.lower()
|
||||
|
||||
|
||||
# -- Границы периода передаются в API как есть --
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_fetch_passes_date_boundaries_to_api_unchanged(mock_redmine_class):
|
||||
"""Флоринг границ выполняет cli-парсер, клиент отдаёт даты в filter как есть."""
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
mock_redmine.time_entry.filter.return_value = []
|
||||
|
||||
fetch_issues_with_spent_time("2026-09-30", "2026-10-31", user_id=458)
|
||||
|
||||
mock_redmine.time_entry.filter.assert_called_once_with(
|
||||
user_id=458, from_date="2026-09-30", to_date="2026-10-31"
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.client.Redmine")
|
||||
def test_fetch_works_without_dedup_argument(mock_redmine_class):
|
||||
"""dedup-слой удалён: без dedup-аргумента все записи периода агрегируются."""
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
_configure_current_user(mock_redmine, user_id=123)
|
||||
|
||||
entry = mock.MagicMock()
|
||||
entry.issue.id = 1
|
||||
entry.hours = 2.0
|
||||
entry.created_on = None
|
||||
entry.updated_on = None
|
||||
mock_redmine.time_entry.filter.return_value = [entry]
|
||||
|
||||
mock_issue = mock.MagicMock()
|
||||
mock_issue.id = 1
|
||||
mock_issue.project = "P"
|
||||
mock_issue.subject = "T"
|
||||
mock_issue.status = "New"
|
||||
mock_redmine.issue.filter.return_value = [mock_issue]
|
||||
|
||||
result = fetch_issues_with_spent_time("2026-09-30", "2026-09-30", user_id=123)
|
||||
|
||||
assert result is not None
|
||||
assert [r[0].id for r in result] == [1]
|
||||
assert result[0][1] == 2.0
|
||||
|
||||
Reference in New Issue
Block a user