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:
@@ -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]] = {}
|
||||
|
||||
Reference in New Issue
Block a user