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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user