Files
redmine-reporter/redmine_reporter/config.py
Кокос Артем Николаевич 47152f8f04 feat: output path defaults (#43) + datetime precision & dedup (#47)
#43 — Имя файла и пути по умолчанию:
- resolve_output_path() в yaml_config.py: резолвит --output с учётом
  output.dir, filename_template, default_format из YAML-конфига
- Bare format (xlsx/odt/...) → путь по шаблону
- Без расширения → автодописывается default_format (.xlsx)
- Config.get_output_dir/filename/default_format()

#47 — datetime precision и дедупликация:
- period.last_used.from/to в YAML-конфиге и AppConfig
- period.precision: date|datetime
- _compute_dedup_cutoff() в cli.py: при precision=datetime вычисляет
  cutoff из period.last_used.to
- _parse_datetime() + AND-логика дедупликации в client.py:
  запись исключается если created_on И updated_on < cutoff
- Пропуск issues без часов после дедупликации

Closes #43
Closes #47
2026-07-07 10:16:46 +07:00

376 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import os
from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path
from typing import Union
import yaml
from dotenv import load_dotenv
from .yaml_config import check_file_permissions, resolve_env_vars
load_dotenv(override=False)
logger = logging.getLogger(__name__)
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
FALSE_VALUES = {"0", "false", "no", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"}
@dataclass
class SmtpConfig:
host: str = ""
port: int = 587
user: str = ""
password: str = ""
tls: bool = True
@dataclass
class EmailConfig:
smtp: SmtpConfig = field(default_factory=SmtpConfig)
from_: str = ""
to: list = field(default_factory=list)
cc: list = field(default_factory=list)
bcc: list = field(default_factory=list)
subject: str = "Отчёт {author} за {period}"
body_text: str = "Во вложении отчёт."
attach: bool = True
@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 = ""
output_dir: str = ""
output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx"
email: EmailConfig = field(default_factory=EmailConfig)
@classmethod
def from_yaml(cls, path: Union[str, Path]) -> "AppConfig":
"""Загружает настройки из YAML-файла.
Если файл не существует — возвращает конфиг со значениями по умолчанию.
Неизвестные ключи верхнего уровня логируются с warning.
"""
path = Path(path)
if not path.exists():
return cls()
# Проверяем права файла
for warning in check_file_permissions(path):
logger.warning(warning)
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
# Предупреждаем о неизвестных ключах верхнего уровня
known_keys = {
"redmine",
"period",
"output",
"email",
}
for key in raw:
if key not in known_keys:
logger.warning("Unknown top-level key in config: %s", key)
return cls(
redmine_url=cls._resolve_str(raw, "redmine", "url"),
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"),
output_dir=cls._resolve_str(raw, "output", "dir"),
output_filename=cls._resolve_str(raw, "output", "filename")
or "{author}_{from}_{to}.{ext}",
output_default_format=cls._resolve_str(raw, "output", "default_format") or "xlsx",
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)
for key in keys:
if isinstance(value, dict):
value = value.get(key)
else:
value = None
break
if isinstance(value, str):
return resolve_env_vars(value)
if value is None:
return ""
return str(value)
@staticmethod
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
value = raw.get(section, {}).get(key)
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.lower()
if normalized in TRUE_VALUES:
return True
if normalized in FALSE_VALUES:
return False
return False
@classmethod
def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
value = raw.get("redmine", {}).get("verify_ssl")
if value is None:
return DEFAULT_REDMINE_VERIFY
if isinstance(value, bool):
return DEFAULT_REDMINE_VERIFY if value else False
if isinstance(value, str):
normalized = value.lower()
if normalized in FALSE_VALUES:
return False
if normalized in TRUE_VALUES:
return DEFAULT_REDMINE_VERIFY
return resolve_env_vars(value)
return DEFAULT_REDMINE_VERIFY
@classmethod
def _resolve_email(cls, raw: dict) -> EmailConfig:
email_raw = raw.get("email", {}) or {}
smtp_raw = email_raw.get("smtp", {}) or {}
return EmailConfig(
smtp=SmtpConfig(
host=cls._safe_str(smtp_raw.get("host")),
port=cls._safe_int(smtp_raw.get("port"), 587),
user=cls._safe_str(smtp_raw.get("user")),
password=resolve_env_vars(cls._safe_str(smtp_raw.get("password"))),
tls=cls._safe_bool(smtp_raw.get("tls"), True),
),
from_=cls._safe_str(email_raw.get("from")),
to=cls._safe_list(email_raw.get("to")),
cc=cls._safe_list(email_raw.get("cc")),
bcc=cls._safe_list(email_raw.get("bcc")),
subject=resolve_env_vars(
cls._safe_str(email_raw.get("subject")) or "Отчёт {author} за {period}"
),
body_text=resolve_env_vars(
cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт."
),
attach=cls._safe_bool(email_raw.get("attach"), True),
)
@staticmethod
def _safe_str(value) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
return str(value)
@staticmethod
def _safe_int(value, default: int = 0) -> int:
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
@staticmethod
def _safe_bool(value, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.lower()
if normalized in TRUE_VALUES:
return True
if normalized in FALSE_VALUES:
return False
return default
@staticmethod
def _safe_list(value) -> list:
if value is None:
return []
if isinstance(value, list):
return [str(v) for v in value]
return [str(value)]
class Config:
_cli_url: str | None = None
_cli_api_key: str | None = None
_app: AppConfig | None = None
@classmethod
def load_yaml(cls, path: str) -> None:
"""Загружает YAML-конфиг. Не бросает исключений при отсутствии файла."""
cls._app = AppConfig.from_yaml(path)
@classmethod
def load_config(cls, path: str) -> None:
"""Загружает переменные из указанного .env-файла с override."""
load_dotenv(path, override=True)
@classmethod
def set_redmine_url(cls, url: str) -> None:
cls._cli_url = url.strip().rstrip("/") if url else None
@classmethod
def set_redmine_api_key(cls, key: str) -> None:
cls._cli_api_key = key.strip() if key else None
@classmethod
def get_redmine_url(cls) -> str:
if cls._cli_url is not None:
return cls._cli_url
env = os.getenv("REDMINE_URL", "").strip().rstrip("/")
if env:
return env
if cls._app:
return cls._app.redmine_url.strip().rstrip("/")
return ""
@classmethod
def get_redmine_api_key(cls) -> str:
if cls._cli_api_key is not None:
return cls._cli_api_key
env = os.getenv("REDMINE_API_KEY", "").strip()
if env:
return env
if cls._app:
return cls._app.redmine_api_key
return ""
@classmethod
def get_redmine_user(cls) -> str:
return os.getenv("REDMINE_USER", "").strip()
@classmethod
def get_redmine_password(cls) -> str:
return os.getenv("REDMINE_PASSWORD", "").strip()
@classmethod
def get_redmine_verify(cls) -> Union[bool, str]:
value = os.getenv("REDMINE_VERIFY", "").strip()
if value:
normalized = value.lower()
if normalized in FALSE_VALUES:
return False
if normalized in TRUE_VALUES:
return True
return value
if cls._app:
return cls._app.redmine_verify
return DEFAULT_REDMINE_VERIFY
@classmethod
def get_author(cls, cli_author: str = "") -> str:
if cli_author:
return cli_author
env = os.getenv("REDMINE_AUTHOR", "").strip()
if env:
return env
if cls._app:
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:
return cls._app.output_dir
return ""
@classmethod
def get_output_filename(cls) -> str:
if cls._app:
return cls._app.output_filename or "{author}_{from}_{to}.{ext}"
return "{author}_{from}_{to}.{ext}"
@classmethod
def get_output_default_format(cls) -> str:
if cls._app:
return cls._app.output_default_format or "xlsx"
return "xlsx"
@classmethod
def get_default_date_range(cls) -> str:
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
if from_env and to_env:
return f"{from_env}--{to_env}"
if cls._app and cls._app.period_default_from and cls._app.period_default_to:
return f"{cls._app.period_default_from}--{cls._app.period_default_to}"
# fallback: текущий месяц
today = date.today()
start = today.replace(day=1)
if today.month == 12:
next_month = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month = today.replace(month=today.month + 1, day=1)
end = next_month - timedelta(days=1)
return f"{start.isoformat()}--{end.isoformat()}"
@classmethod
def validate(cls) -> None:
if not cls.get_redmine_url():
raise ValueError("REDMINE_URL is required (set via env or .env)")
if cls.get_redmine_api_key():
return
if not (cls.get_redmine_user() and cls.get_redmine_password()):
raise ValueError(
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
)