feat: status translation overrides via YAML config

report.status_translation in YAML config overrides/extends the builtin
STATUS_TRANSLATION dictionary; without the section behavior is unchanged.

- AppConfig.report_status_translation + _resolve_str_dict (warns and
  skips non-scalar values, resolves ${VAR} references)
- Config.get_status_translation() returns merged copy (lazy import,
  builtin dict never mutated)
- build_grouped_report() accepts optional status_translation parameter
- --init-config template gains commented example, docs/CONFIG.md updated

Closes #65
This commit is contained in:
Кокос Артем Николаевич
2026-07-17 15:18:27 +07:00
parent 09f6062e8c
commit 8614062ecd
7 changed files with 332 additions and 2 deletions

View File

@@ -4,7 +4,7 @@ import sys
from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path
from typing import Union
from typing import Dict, Union
import yaml
from dotenv import load_dotenv
@@ -62,6 +62,7 @@ class AppConfig:
output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx"
report_no_time: bool = False
report_status_translation: Dict[str, str] = field(default_factory=dict)
email: EmailConfig = field(default_factory=EmailConfig)
@classmethod
@@ -111,6 +112,9 @@ class AppConfig:
output_default_format=cls._resolve_str(raw, "output", "default_format")
or "xlsx",
report_no_time=cls._resolve_bool(raw, "report", "no_time"),
report_status_translation=cls._resolve_str_dict(
raw, "report", "status_translation"
),
email=cls._resolve_email(raw),
)
@@ -140,6 +144,43 @@ class AppConfig:
return ""
return str(value)
@staticmethod
def _resolve_str_dict(raw: dict, section: str, key: str) -> Dict[str, str]:
"""Читает вложенный mapping строк из YAML-секции.
Не-mapping значение логируется с warning и игнорируется.
Ключи и значения приводятся к str, ${VAR} в значениях резолвится.
Записи со значением null или не-scalar (dict/list) пропускаются
с warning — иначе null стал бы строкой "None", а dict — repr.
"""
section_value = raw.get(section)
if not isinstance(section_value, dict):
return {}
value = section_value.get(key)
if value is None:
return {}
if not isinstance(value, dict):
logger.warning(
"Config %s.%s must be a mapping, got %s — ignoring",
section,
key,
type(value).__name__,
)
return {}
result: Dict[str, str] = {}
for k, v in value.items():
if v is None or isinstance(v, (dict, list)):
logger.warning(
"Config %s.%s entry %r must be a scalar, got %s — skipping",
section,
key,
k,
type(v).__name__,
)
continue
result[str(k)] = resolve_env_vars(str(v))
return result
@staticmethod
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
value = raw.get(section, {}).get(key)
@@ -367,6 +408,18 @@ class Config:
return cls._app.report_no_time
return False
@classmethod
def get_status_translation(cls) -> Dict[str, str]:
"""Полный словарь перевода статусов: встроенный + переопределения из YAML.
Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется.
Импорт ленивый: config.py не должен тянуть redminelib при импорте.
"""
from .report_builder import STATUS_TRANSLATION
overrides = cls._app.report_status_translation if cls._app else {}
return {**STATUS_TRANSLATION, **overrides}
@classmethod
def get_email_config(cls) -> "EmailConfig | None":
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""