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:
@@ -136,6 +136,16 @@ def _run_init_config(config_path: str, force: bool) -> int:
|
||||
yaml.dump(
|
||||
data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
|
||||
)
|
||||
fh.write(
|
||||
"\n"
|
||||
"# report.status_translation: переопределение/дополнение перевода\n"
|
||||
"# статусов Redmine. Без секции используется встроенный словарь.\n"
|
||||
"# Пример:\n"
|
||||
"# report:\n"
|
||||
"# status_translation:\n"
|
||||
'# "New": "Новая"\n'
|
||||
'# "Wait Release": "Ожидает релиза"\n'
|
||||
)
|
||||
path.chmod(0o600)
|
||||
|
||||
sections_found = [s for s in data if data[s]]
|
||||
@@ -445,6 +455,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
issue_hours,
|
||||
fill_time=not no_time,
|
||||
by_activity=args.by_activity,
|
||||
status_translation=Config.get_status_translation(),
|
||||
)
|
||||
|
||||
if args.summary:
|
||||
|
||||
@@ -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, если не настроен."""
|
||||
|
||||
@@ -35,6 +35,7 @@ def build_grouped_report(
|
||||
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]],
|
||||
fill_time: bool = True,
|
||||
by_activity: bool = False,
|
||||
status_translation: Optional[Dict[str, str]] = None,
|
||||
) -> List[ReportRow]:
|
||||
"""
|
||||
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
||||
@@ -42,6 +43,11 @@ def build_grouped_report(
|
||||
|
||||
Предусловие: issue_hours должен быть отсортирован по (project, version).
|
||||
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
|
||||
|
||||
status_translation: перевод статусов; None — встроенный STATUS_TRANSLATION.
|
||||
Переданный словарь ЗАМЕНЯЕТ встроенный полностью (без merge): статус,
|
||||
отсутствующий в нём, выводится как есть (passthrough); пустой dict —
|
||||
все статусы выводятся как есть.
|
||||
"""
|
||||
|
||||
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
||||
@@ -49,6 +55,10 @@ def build_grouped_report(
|
||||
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
|
||||
)
|
||||
|
||||
translation = (
|
||||
status_translation if status_translation is not None else STATUS_TRANSLATION
|
||||
)
|
||||
|
||||
rows: List[ReportRow] = []
|
||||
prev_project: str = ""
|
||||
prev_version: str = ""
|
||||
@@ -58,7 +68,7 @@ def build_grouped_report(
|
||||
project = str(issue.project)
|
||||
version = get_version(issue)
|
||||
status_en = str(issue.status)
|
||||
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
|
||||
status_ru = translation.get(status_en, status_en)
|
||||
|
||||
if fill_time:
|
||||
if by_activity and activities:
|
||||
|
||||
Reference in New Issue
Block a user