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:
Кокос Артем Николаевич
2026-09-30 12:37:00 +07:00
parent db069dfb4b
commit 27d21ef639
11 changed files with 726 additions and 1365 deletions

View File

@@ -1,5 +1,4 @@
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union
import requests
@@ -163,33 +162,6 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str:
return str(activity)
def _parse_datetime(value: Any) -> Optional[datetime]:
"""Parse a datetime from Redmine API response.
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 _ensure_aware_utc(value)
if isinstance(value, str):
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
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] = []
@@ -278,18 +250,12 @@ def fetch_issues_with_spent_time(
to_date: str,
user_id: Optional[Union[int, str]] = None,
by_activity: bool = False,
dedup_before: Optional[datetime] = None,
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]:
"""
Fetch unique issues linked to time entries of the given user in date range,
along with total spent hours per issue.
If user_id is None, uses current user.
If by_activity is True, returns per-activity breakdown as third tuple element.
If dedup_before is set, entries with both fields set are kept only when
created_on AND updated_on are both >= dedup_before; an entry is excluded
if at least one of the fields is before the cutoff (dedup_before).
Entries with both fields missing (None) are kept;
if only one field is set, that field alone decides (>= cutoff keeps the entry).
Returns list of (issue, total_hours, activities) tuples.
Raises RedmineAPIError on API/auth/network failures.
"""
@@ -304,7 +270,9 @@ def fetch_issues_with_spent_time(
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
time_entries = list(
redmine.time_entry.filter(
user_id=target_user_id, from_date=from_date, to_date=to_date
user_id=target_user_id,
from_date=from_date,
to_date=to_date,
)
)
except RedmineAPIError:
@@ -312,38 +280,6 @@ def fetch_issues_with_spent_time(
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте.
# Если оба поля заданы, запись сохраняется, только если created_on И updated_on
# оба >= dedup_before; если хотя бы одно из полей < dedup_before,
# запись исключается.
# Если оба поля None — запись сохраняется; если задано только одно поле,
# решает оно (>= dedup_before → запись сохраняется).
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 = []
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:
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 (и активности, если требуется)
spent_time: Dict[int, float] = {}
spent_by_activity: Dict[int, Dict[str, float]] = {}