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 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 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"), 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, key: str) -> str: value = raw.get(section, {}).get(key, "") 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 value if isinstance(value, str): normalized = value.lower() if normalized in FALSE_VALUES: return False if normalized in TRUE_VALUES: return True 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_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" )