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