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

@@ -3,7 +3,7 @@ import logging
import os
import re
import sys
from datetime import datetime, timezone
from datetime import datetime
from pathlib import Path
from typing import List, Optional
@@ -39,35 +39,38 @@ def _sanitize_error_text(text: str) -> str:
return text
def _parse_boundary(value: str) -> datetime:
"""Разбирает одну границу диапазона как дату или datetime без timezone."""
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
fmt = "%Y-%m-%d"
elif re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", value):
fmt = "%Y-%m-%dT%H:%M:%S"
else:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try:
return datetime.strptime(value, fmt)
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
def parse_date_range(date_arg: str) -> tuple[str, str]:
"""Возвращает границы диапазона в виде дат YYYY-MM-DD.
Каждая граница может быть датой или datetime; время отбрасывается
(границы окна -- целые дни), поэтому смешение форматов допустимо.
"""
if "--" not in date_arg:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
parts = date_arg.split("--", 1)
from_date, to_date = parts[0].strip(), parts[1].strip()
date_pattern = r"\d{4}-\d{2}-\d{2}"
datetime_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
if re.fullmatch(date_pattern, from_date) and re.fullmatch(date_pattern, to_date):
fmt = "%Y-%m-%d"
elif re.fullmatch(datetime_pattern, from_date) and re.fullmatch(
datetime_pattern, to_date
):
fmt = "%Y-%m-%dT%H:%M:%S"
else:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
start = _parse_boundary(from_date)
end = _parse_boundary(to_date)
try:
start = datetime.strptime(from_date, fmt)
end = datetime.strptime(to_date, fmt)
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
if start > end:
if start.date() > end.date():
raise ValueError("Date range start must be less than or equal to end")
if fmt == "%Y-%m-%d":
return start.date().isoformat(), end.date().isoformat()
return start.isoformat(), end.isoformat()
return start.date().isoformat(), end.date().isoformat()
def _run_init_config(config_path: str, force: bool) -> int:
@@ -99,10 +102,7 @@ def _run_init_config(config_path: str, force: bool) -> int:
"verify_ssl": True,
},
"period": {
"precision": "date",
"default_from": os.getenv("DEFAULT_FROM_DATE", "").strip(),
"default_to": os.getenv("DEFAULT_TO_DATE", "").strip(),
"dynamic": False,
"from": os.getenv("FROM_DATE", "").strip(),
},
"output": {
"dir": "",
@@ -155,29 +155,6 @@ def _run_init_config(config_path: str, force: bool) -> int:
return 0
def _compute_dedup_cutoff() -> Optional[datetime]:
"""Compute deduplication cutoff from config.
If period.precision == 'datetime' and period.last_used.to is set,
return the datetime to filter out entries already accounted for.
Otherwise return None.
"""
if Config.get_period_precision() != "datetime":
return None
last_to = Config.get_last_used_to()
if not last_to:
return None
try:
dt = datetime.fromisoformat(last_to.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except (ValueError, TypeError):
return None
def _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool:
"""Возвращает финальное значение no_time.
@@ -201,10 +178,11 @@ def _save_and_maybe_send(
to_date: str,
no_time: bool,
do_send: bool,
) -> int:
) -> tuple[int, bool]:
"""Сохраняет отчёт в файл и опционально отправляет по email.
Returns 0 on success, 1 on error.
Returns (exit_code, sent): sent отражает факт реальной отправки письма,
чтобы main мог отличить отказ от подтверждения от успешной отправки.
"""
output_ext = os.path.splitext(output_arg)[1].lower()
@@ -213,7 +191,7 @@ def _save_and_maybe_send(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr,
)
return 1
return 1, False
formatter = get_formatter_by_extension(
output_ext,
@@ -236,7 +214,7 @@ def _save_and_maybe_send(
f"Поддерживаются: {known_exts}",
file=sys.stderr,
)
return 1
return 1, False
try:
formatter.save(rows, output_arg)
@@ -244,7 +222,7 @@ def _save_and_maybe_send(
except Exception as e:
fmt = output_ext.lstrip(".").upper()
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
return 1
return 1, False
if do_send:
email_config = Config.get_email_config()
@@ -254,7 +232,7 @@ def _save_and_maybe_send(
"(~/.config/redmine-reporter/config.yml).",
file=sys.stderr,
)
return 1
return 1, False
print("\n📧 Готово к отправке:")
print(f" From: {email_config.from_}")
@@ -267,7 +245,7 @@ def _save_and_maybe_send(
response = input("Отправить? [y/N]: ").strip().lower()
if response not in ("y", "yes"):
print("⏭️ Отправка отменена.")
return 0
return 0, False
try:
send_report(
@@ -280,9 +258,10 @@ def _save_and_maybe_send(
print(f"📧 Report sent to {', '.join(email_config.to)}")
except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
return 1
return 1, False
return 0, True
return 0
return 0, False
def main(argv: Optional[List[str]] = None) -> int:
@@ -363,12 +342,12 @@ def main(argv: Optional[List[str]] = None) -> int:
parser.add_argument(
"--commit",
action="store_true",
help="Save used period as last_used in YAML config and auto-commit to file",
help="Save the report and advance period start to its end date in YAML config",
)
parser.add_argument(
"--send",
action="store_true",
help="Send generated report via email after saving (requires email section in config)",
help="Send generated report via email after saving (requires email section in config); on success advance period start to its end date",
)
args = parser.parse_args(argv)
@@ -442,7 +421,6 @@ def main(argv: Optional[List[str]] = None) -> int:
to_date,
user_id=args.user_id or args.user_login or args.user_name,
by_activity=args.by_activity,
dedup_before=_compute_dedup_cutoff(),
)
except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
@@ -485,6 +463,9 @@ def main(argv: Optional[List[str]] = None) -> int:
activity = key.split(":", 1)[1]
print(f" [{activity}]: {value}h", file=sys.stderr)
save_ok = False
report_sent = False
if args.output or args.commit:
if args.output:
output_arg = resolve_output_path(
@@ -515,7 +496,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
return 1
ret = _save_and_maybe_send(
ret, report_sent = _save_and_maybe_send(
rows,
output_arg,
author=Config.get_author(args.author),
@@ -526,6 +507,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
if ret != 0:
return ret
save_ok = True
elif args.send:
# --send без --output и --commit: сохраняем по шаблону и отправляем
@@ -546,7 +528,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
return 1
ret = _save_and_maybe_send(
ret, report_sent = _save_and_maybe_send(
rows,
output_arg,
author=Config.get_author(args.author),
@@ -557,6 +539,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
if ret != 0:
return ret
save_ok = True
else:
if args.compact:
@@ -575,23 +558,17 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1
if args.commit:
precision = Config.get_period_precision()
dynamic = Config._app.period_dynamic if Config._app else False
if precision == "datetime":
# from -- фактическое начало окна отчёта; to -- дедуп-маркер (naive UTC).
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
from_str = from_date
to_str = now
else:
from_str = from_date
to_str = to_date
# Окно двигается ровно один раз за запуск: либо по явному --commit
# (операция завершилась кодом 0), либо по фактически отправленному письму --send.
should_advance = (args.commit and save_ok) or (args.send and report_sent)
if should_advance:
# Начало следующего окна совпадает с концом текущего: граничный день
# перечитывается. Дубли записей дня коммита осознанны, зато поздние
# записи, добавленные в тот же день после отправки, не теряются.
next_from = to_date
try:
save_period_to_config(
args.config_path, from_str, to_str, precision, dynamic
)
save_period_to_config(args.config_path, from_str=next_from)
except Exception as e:
print(
f"❌ Не удалось сохранить период в конфиг: {e}",
@@ -599,7 +576,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
return 1
print(
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
f"📌 Period committed [{next_from}] → {args.config_path}",
file=sys.stderr,
)

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]] = {}

View File

@@ -2,7 +2,7 @@ import logging
import os
import sys
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from datetime import date
from pathlib import Path
from typing import Dict, Union
@@ -40,24 +40,13 @@ class EmailConfig:
html: bool = False
@dataclass
class PeriodLastUsed:
from_: str = ""
to: str = ""
@dataclass
class AppConfig:
redmine_url: str = ""
redmine_api_key: str = ""
redmine_author: str = ""
redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY
period_precision: str = "date"
period_default_from: str = ""
period_default_to: str = ""
period_dynamic: bool = False
period_last_used_from: str = ""
period_last_used_to: str = ""
period_from: str = ""
output_dir: str = ""
output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx"
@@ -100,12 +89,7 @@ class AppConfig:
redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"),
redmine_author=cls._resolve_str(raw, "redmine", "author"),
redmine_verify=cls._resolve_verify(raw),
period_precision=cls._resolve_str(raw, "period", "precision") or "date",
period_default_from=cls._resolve_str(raw, "period", "default_from"),
period_default_to=cls._resolve_str(raw, "period", "default_to"),
period_dynamic=cls._resolve_bool(raw, "period", "dynamic"),
period_last_used_from=cls._resolve_str(raw, "period", "last_used", "from"),
period_last_used_to=cls._resolve_str(raw, "period", "last_used", "to"),
period_from=cls._resolve_str(raw, "period", "from"),
output_dir=cls._resolve_str(raw, "output", "dir"),
output_filename=cls._resolve_str(raw, "output", "filename")
or "{author}_{from}_{to}.{ext}",
@@ -118,17 +102,6 @@ class AppConfig:
email=cls._resolve_email(raw),
)
@classmethod
def from_env(cls) -> "AppConfig":
"""Загружает настройки из переменных окружения."""
return cls(
redmine_url=os.getenv("REDMINE_URL", "").strip().rstrip("/"),
redmine_api_key=os.getenv("REDMINE_API_KEY", "").strip(),
redmine_author=os.getenv("REDMINE_AUTHOR", "").strip(),
period_default_from=os.getenv("DEFAULT_FROM_DATE", "").strip(),
period_default_to=os.getenv("DEFAULT_TO_DATE", "").strip(),
)
@staticmethod
def _resolve_str(raw: dict, section: str, *keys: str) -> str:
value = raw.get(section)
@@ -365,24 +338,6 @@ class Config:
return cls._app.redmine_author
return ""
@classmethod
def get_period_precision(cls) -> str:
if cls._app:
return cls._app.period_precision or "date"
return "date"
@classmethod
def get_last_used_from(cls) -> str:
if cls._app:
return cls._app.period_last_used_from
return ""
@classmethod
def get_last_used_to(cls) -> str:
if cls._app:
return cls._app.period_last_used_to
return ""
@classmethod
def get_output_dir(cls) -> str:
if cls._app:
@@ -432,50 +387,35 @@ class Config:
@classmethod
def get_default_date_range(cls) -> str:
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
"""Окно отчёта по умолчанию: from..сегодня.
Приоритет начала: env FROM_DATE -> YAML period.from -> 1-е число
текущего месяца. Некорректная дата не роняет CLI -- логируется
warning и берётся текущий месяц.
"""
today = date.today()
today_str = today.isoformat()
raw_from = os.getenv("FROM_DATE", "").strip()
if not raw_from and cls._app:
raw_from = cls._app.period_from
if from_env:
return f"{from_env}--{to_env or today_str}"
if raw_from:
try:
start = date.fromisoformat(raw_from)
except (ValueError, TypeError):
logger.warning(
"Invalid FROM_DATE/period.from %r -- expected ISO date "
"(YYYY-MM-DD); using current month",
raw_from,
)
start = today.replace(day=1)
else:
start = today.replace(day=1)
if (
cls._app
and cls._app.period_dynamic
and (cls._app.period_precision or "date") == "datetime"
and cls._app.period_last_used_to
):
last_to = datetime.fromisoformat(
cls._app.period_last_used_to.replace("Z", "+00:00")
)
if last_to.tzinfo is None:
last_to = last_to.replace(tzinfo=timezone.utc)
else:
last_to = last_to.astimezone(timezone.utc)
now_utc = datetime.now(timezone.utc)
fmt = "%Y-%m-%dT%H:%M:%S"
return f"{last_to.strftime(fmt)}--{now_utc.strftime(fmt)}"
if (
cls._app
and cls._app.period_dynamic
and cls._app.period_last_used_from
and cls._app.period_last_used_to
):
nf, nt = compute_next_period(
cls._app.period_last_used_from,
cls._app.period_last_used_to,
cls._app.period_precision or "date",
)
return f"{nf}--{nt}"
if cls._app and cls._app.period_default_from:
default_to = cls._app.period_default_to or today_str
return f"{cls._app.period_default_from}--{default_to}"
start = today.replace(day=1)
return f"{start.isoformat()}--{today_str}"
# Будущая start инвертирует диапазон и ломает фильтр Redmine,
# поэтому сжимаем окно до сегодняшнего дня.
if start > today:
start = today
return f"{start.isoformat()}--{today.isoformat()}"
@classmethod
def validate(cls) -> None:
@@ -500,52 +440,3 @@ class Config:
raise ValueError(
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
)
def compute_next_period(
last_from: str, last_to: str, precision: str
) -> tuple[str, str]:
"""Compute the next report period based on the last committed period.
- For a full calendar month → next full calendar month.
- For an arbitrary range → same duration, starting the day after last_to.
- For datetime precision → same duration, starting 1 second after last_to.
"""
from datetime import datetime as dt_mod
if precision == "datetime":
fmt = "%Y-%m-%dT%H:%M:%S"
from_dt = dt_mod.fromisoformat(last_from.replace("Z", "+00:00"))
to_dt = dt_mod.fromisoformat(last_to.replace("Z", "+00:00"))
duration = to_dt - from_dt
next_from_dt = to_dt + timedelta(seconds=1)
next_to_dt = next_from_dt + duration
return next_from_dt.strftime(fmt), next_to_dt.strftime(fmt)
from_parts = last_from.split("-")
to_parts = last_to.split("-")
from_d = date(int(from_parts[0]), int(from_parts[1]), int(from_parts[2]))
to_d = date(int(to_parts[0]), int(to_parts[1]), int(to_parts[2]))
first_of_month = from_d.replace(day=1)
if from_d == first_of_month:
if to_d.month == 12:
last_of_month = date(to_d.year, 12, 31)
else:
next_first = date(to_d.year, to_d.month + 1, 1)
last_of_month = next_first - timedelta(days=1)
if to_d == last_of_month:
if to_d.month == 12:
nstart = date(to_d.year + 1, 1, 1)
else:
nstart = date(to_d.year, to_d.month + 1, 1)
if nstart.month == 12:
nend = date(nstart.year, 12, 31)
else:
nend = date(nstart.year, nstart.month + 1, 1) - timedelta(days=1)
return nstart.isoformat(), nend.isoformat()
duration = to_d - from_d
next_from = to_d + timedelta(days=1)
next_to = next_from + duration
return next_from.isoformat(), next_to.isoformat()

View File

@@ -153,19 +153,11 @@ def resolve_output_path(
return arg
def save_period_to_config(
config_path: str,
from_str: str,
to_str: str,
precision: str,
dynamic: bool,
) -> None:
"""Save committed period to YAML config file.
def save_period_to_config(config_path: str, from_str: str) -> None:
"""Save committed period start to YAML config file.
Writes period.last_used.from / period.last_used.to.
When dynamic=False, also overwrites period.default_from / period.default_to.
Preserves all other sections unchanged. Creates config file if missing.
Writes period.from only: all other sections and keys stay untouched.
Creates the config file (and its parent directory) if missing.
"""
import yaml
@@ -176,11 +168,7 @@ def save_period_to_config(
raw = yaml.safe_load(fh) or {}
period = raw.setdefault("period", {})
period["last_used"] = {"from": from_str, "to": to_str}
if not dynamic:
period["default_from"] = from_str.split("T")[0] if "T" in from_str else from_str
period["default_to"] = to_str.split("T")[0] if "T" in to_str else to_str
period["from"] = from_str
ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh: