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