diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 2fa65dd..1be67f7 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -330,6 +330,7 @@ email: | Поле | Тип | По умолчанию | Описание | |---|---|---|---| | `no_time` | bool | `false` | Не включать затраченное время в файл отчёта | +| `status_translation` | map[str,str] | `{}` | Переопределение/дополнение перевода статусов | `report.no_time` применяется только в автоматических режимах (`--commit`, `--send`). При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`. @@ -353,6 +354,22 @@ redmine-reporter --output report.odt redmine-reporter --output report.odt --no-time ``` +### `report.status_translation` — перевод статусов + +По умолчанию статусы Redmine переводятся встроенным словарём +(`New` → `В работе`, `Closed` → `Закрыто` и т.д.). Секция +`report.status_translation` переопределяет отдельные переводы и/или +добавляет новые статусы; не указанные здесь статусы переводятся +встроенным словарём, а совсем неизвестные выводятся как есть. + +```yaml +report: + status_translation: + "New": "Новая" + "Wait Release": "Ожидает релиза" + "Custom Status": "Кастомный статус" +``` + ## Разрешение выходного пути Функция `resolve_output_path()` определяет итоговый путь к файлу: diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 1ed712b..ead848e 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -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: diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 55efb47..1c20fb3 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -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, если не настроен.""" diff --git a/redmine_reporter/report_builder.py b/redmine_reporter/report_builder.py index 044edae..d60dd38 100644 --- a/redmine_reporter/report_builder.py +++ b/redmine_reporter/report_builder.py @@ -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: diff --git a/tests/test_cli.py b/tests/test_cli.py index e362ec2..94157ce 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -483,6 +483,55 @@ class TestInitConfig: assert "report" in data assert data["report"]["no_time"] is False + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + def test_init_config_includes_status_translation_example(self, tmp_path): + """#65: --init-config дописывает закомментированный пример status_translation. + + Файл остаётся валидным YAML, активной секции status_translation нет. + """ + import yaml + + config_path = tmp_path / "config.yml" + code = main(["--init-config", "--config-path", str(config_path)]) + assert code == 0 + + text = config_path.read_text(encoding="utf-8") + assert "# status_translation:" in text + + with open(config_path) as fh: + data = yaml.safe_load(fh) + + assert "status_translation" not in data["report"] + + +@mock.patch.dict(os.environ, VALID_ENV, clear=True) +@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") +def test_cli_status_translation_from_yaml_reaches_builder(mock_fetch, tmp_path): + """#65: report.status_translation из YAML доезжает до build_grouped_report.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + config_path = tmp_path / "config.yml" + config_path.write_text( + "report:\n" + " status_translation:\n" + ' New: "Новая"\n' + ' "Custom Status": "Кастом"\n' + ) + + with mock.patch("redmine_reporter.cli.build_grouped_report") as mock_build: + mock_build.return_value = [] + code = main( + ["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)] + ) + + assert code == 0 + _, kwargs = mock_build.call_args + translation = kwargs["status_translation"] + assert translation["New"] == "Новая" + assert translation["Custom Status"] == "Кастом" + # встроенные переводы сохранились + assert translation["Closed"] == "Закрыто" + @mock.patch.dict(os.environ, {}, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") diff --git a/tests/test_config.py b/tests/test_config.py index 8922100..2e63738 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -907,3 +907,169 @@ class TestReportNoTime: Config._app = AppConfig.from_yaml(yaml_path) assert Config.get_report_no_time() is False + + +# --------------------------------------------------------------------------- +# #65: report.status_translation — перевод статусов из YAML +# --------------------------------------------------------------------------- + + +class TestStatusTranslationFromYaml: + """Tests for report.status_translation loading in AppConfig.from_yaml().""" + + def test_status_translation_loaded_from_yaml(self): + """report.status_translation заполняет поле AppConfig (override + новый).""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "report:\n" + " status_translation:\n" + ' New: "Новая"\n' + ' "Custom Status": "Кастом"\n' + ) + + cfg = AppConfig.from_yaml(yaml_path) + + assert cfg.report_status_translation == { + "New": "Новая", + "Custom Status": "Кастом", + } + + def test_status_translation_missing_defaults_to_empty(self): + """Без секции report.status_translation поле пустое.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("report:\n no_time: true\n") + + cfg = AppConfig.from_yaml(yaml_path) + + assert cfg.report_status_translation == {} + + def test_status_translation_non_mapping_warns_and_ignored(self, caplog): + """status_translation не-mapping (строка) → warning в лог, поле {}.""" + import logging + + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("report:\n status_translation: 'just a string'\n") + + with caplog.at_level(logging.WARNING): + cfg = AppConfig.from_yaml(yaml_path) + + assert cfg.report_status_translation == {} + assert "status_translation" in caplog.text + + @mock.patch.dict(os.environ, {"STATUS_RU": "На проверке"}, clear=True) + def test_status_translation_env_var_resolved(self): + """${VAR} в значении перевода резолвится из окружения.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "report:\n status_translation:\n Testing: ${STATUS_RU}\n" + ) + + cfg = AppConfig.from_yaml(yaml_path) + + assert cfg.report_status_translation == {"Testing": "На проверке"} + + def test_status_translation_null_value_skipped_with_warning(self, caplog): + """Значение null пропускается с warning, а не превращается в 'None'.""" + import logging + + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "report:\n status_translation:\n New:\n Closed: Закрыто\n" + ) + + with caplog.at_level(logging.WARNING): + cfg = AppConfig.from_yaml(yaml_path) + + assert cfg.report_status_translation == {"Closed": "Закрыто"} + assert "status_translation" in caplog.text + assert "New" in caplog.text + + def test_status_translation_nested_dict_skipped_with_warning(self, caplog): + """Вложенный dict в значении пропускается с warning, а не в repr.""" + import logging + + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "report:\n" + " status_translation:\n" + " New:\n" + " nested: value\n" + " Closed: Закрыто\n" + ) + + with caplog.at_level(logging.WARNING): + cfg = AppConfig.from_yaml(yaml_path) + + assert cfg.report_status_translation == {"Closed": "Закрыто"} + assert "status_translation" in caplog.text + assert "New" in caplog.text + + def test_status_translation_int_value_coerced_to_str(self): + """Scalar-значения (int) по-прежнему приводятся к str.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("report:\n status_translation:\n New: 42\n") + + cfg = AppConfig.from_yaml(yaml_path) + + assert cfg.report_status_translation == {"New": "42"} + + +class TestGetStatusTranslation: + """Tests for Config.get_status_translation().""" + + def test_no_yaml_returns_builtin(self): + """Без YAML-конфига возвращается встроенный словарь.""" + from redmine_reporter.report_builder import STATUS_TRANSLATION + + Config._app = None + try: + assert Config.get_status_translation() == STATUS_TRANSLATION + finally: + Config._app = None + + def test_empty_overrides_returns_builtin(self): + """Пустые overrides — результат равен встроенному словарю.""" + from redmine_reporter.report_builder import STATUS_TRANSLATION + + Config._app = AppConfig() + try: + assert Config.get_status_translation() == STATUS_TRANSLATION + finally: + Config._app = None + + def test_override_and_extend(self): + """Override существующего статуса + добавление нового.""" + Config._app = AppConfig( + report_status_translation={"New": "Новая", "Custom Status": "Кастом"} + ) + try: + translation = Config.get_status_translation() + finally: + Config._app = None + + assert translation["New"] == "Новая" + assert translation["Custom Status"] == "Кастом" + # остальные встроенные переводы не тронуты + assert translation["Closed"] == "Закрыто" + + def test_result_is_a_copy(self): + """Мутация результата не трогает модульный STATUS_TRANSLATION.""" + from redmine_reporter.report_builder import STATUS_TRANSLATION + + Config._app = AppConfig(report_status_translation={"New": "Новая"}) + try: + translation = Config.get_status_translation() + translation["New"] = "ИЗМЕНЕНО" + translation["Junk"] = "мусор" + finally: + Config._app = None + + assert STATUS_TRANSLATION["New"] == "В работе" + assert "Junk" not in STATUS_TRANSLATION diff --git a/tests/test_report_builder.py b/tests/test_report_builder.py index 1694285..17b0142 100644 --- a/tests/test_report_builder.py +++ b/tests/test_report_builder.py @@ -277,3 +277,27 @@ def test_group_rows_preserves_row_data(): def test_group_rows_empty(): """Пустой список — пустой словарь.""" assert group_rows_by_project_and_version([]) == {} + + +# -- #65: кастомный перевод статусов -- + + +def test_build_grouped_report_custom_status_translation(): + """status_translation переопределяет встроенный словарь (#65).""" + issue = MockIssue("P", "S", "New", "v1.0", 1) + rows = build_grouped_report([(issue, 1.0)], status_translation={"New": "Новая"}) + assert rows[0]["status_ru"] == "Новая" + + +def test_build_grouped_report_custom_status_translation_passthrough(): + """Статус вне кастомного словаря возвращается как есть (#65).""" + issue = MockIssue("P", "S", "Closed", "v1.0", 1) + rows = build_grouped_report([(issue, 1.0)], status_translation={"New": "Новая"}) + assert rows[0]["status_ru"] == "Closed" + + +def test_build_grouped_report_none_translation_uses_builtin(): + """status_translation=None — используется встроенный словарь (#65).""" + issue = MockIssue("P", "S", "Closed", "v1.0", 1) + rows = build_grouped_report([(issue, 1.0)], status_translation=None) + assert rows[0]["status_ru"] == "Закрыто"