diff --git a/README.md b/README.md index c6c713e..9089b2e 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ CLI-инструмент для генерации отчётов по зада - Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx). - Excel-отчёт с merge-ячейками по проекту/версии, итогами, автошириной, автофильтром и закреплённой шапкой. - Сводка по времени (`--summary`). -- YAML-конфиг (`~/.config/redmine-reporter/config.yml`): шаблон имени файла, путь по умолчанию, период, email. +- YAML-конфиг (`~/.config/redmine-reporter/config.yml`): шаблон имени файла, путь по умолчанию, период, email, настройки содержимого отчёта (`report.no_time`). - Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание. - `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска. - `--send`: отправка отчёта по email через SMTP сразу после генерации. @@ -80,6 +80,9 @@ output: filename: "{author}_{from}_{to}.{ext}" default_format: xlsx +report: + no_time: false + email: smtp: host: smtp.example.com @@ -239,6 +242,8 @@ redmine-reporter --by-activity redmine-reporter --by-activity --summary ``` +`--no-time` можно задать в YAML-конфиге (`report.no_time: true`), чтобы автоматические режимы (`--commit`, `--send`) не включали затраченное время без явного флага. При ручном `--output` YAML-значение не применяется — только CLI-флаг `--no-time`. + Сводка: ```bash diff --git a/docs/CONFIG.md b/docs/CONFIG.md index a448ef6..ac56008 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -43,6 +43,9 @@ output: filename: "{author}_{from}_{to}.{ext}" default_format: xlsx +report: + no_time: false + email: smtp: host: smtp.example.com @@ -273,6 +276,36 @@ email: **не запрещены** — если вписать `api_key: "abc123"` напрямую, система примет. Права `0600` — основная защита. +### `report` — настройки содержимого отчёта + +Секция управляет тем, что попадает в сгенерированный отчёт. + +| Поле | Тип | По умолчанию | Описание | +|---|---|---|---| +| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта | + +`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`). +При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`. +CLI-флаг `--no-time` всегда имеет приоритет над YAML. + +**Примеры:** + +```yaml +report: + no_time: true +``` + +```bash +# Автоматический режим: время не выводится +redmine-reporter --commit + +# Ручной режим: report.no_time игнорируется, время выводится +redmine-reporter --output report.odt + +# Ручной режим с явным флагом: время не выводится +redmine-reporter --output report.odt --no-time +``` + ## Разрешение выходного пути Функция `resolve_output_path()` определяет итоговый путь к файлу: diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 3d5c166..6bbc59f 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -72,6 +72,9 @@ def _run_init_config(config_path: str, force: bool) -> int: "filename": "{author}_{from}_{to}.{ext}", "default_format": "xlsx", }, + "report": { + "no_time": False, + }, "email": { "smtp": { "host": "", @@ -127,6 +130,21 @@ def _compute_dedup_cutoff() -> Optional[datetime]: return None +def _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool: + """Возвращает финальное значение no_time. + + Приоритет: + 1. CLI-флаг --no-time (всегда побеждает). + 2. YAML report.no_time (только в автоматических режимах --commit/--send). + 3. Иначе False. + """ + if cli_flag: + return True + if is_auto_mode: + return Config.get_report_no_time() + return False + + def _save_and_maybe_send( rows, output_arg: str, @@ -378,9 +396,12 @@ def main(argv: Optional[List[str]] = None) -> int: print(f"✅ Total issues: {len(issue_hours)} [{date_arg}]", file=sys.stderr) + is_auto_mode = args.commit or args.send + no_time = _resolve_no_time(args.no_time, is_auto_mode) + rows = build_grouped_report( issue_hours, - fill_time=not args.no_time, + fill_time=not no_time, by_activity=args.by_activity, ) @@ -434,7 +455,7 @@ def main(argv: Optional[List[str]] = None) -> int: author=Config.get_author(args.author), from_date=from_date, to_date=to_date, - no_time=args.no_time, + no_time=no_time, do_send=args.send, ) if ret != 0: @@ -465,7 +486,7 @@ def main(argv: Optional[List[str]] = None) -> int: author=Config.get_author(args.author), from_date=from_date, to_date=to_date, - no_time=args.no_time, + no_time=no_time, do_send=True, ) if ret != 0: diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 898bf37..4bd135a 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -61,6 +61,7 @@ class AppConfig: output_dir: str = "" output_filename: str = "{author}_{from}_{to}.{ext}" output_default_format: str = "xlsx" + report_no_time: bool = False email: EmailConfig = field(default_factory=EmailConfig) @classmethod @@ -86,6 +87,7 @@ class AppConfig: "redmine", "period", "output", + "report", "email", } for key in raw: @@ -108,6 +110,7 @@ class AppConfig: or "{author}_{from}_{to}.{ext}", output_default_format=cls._resolve_str(raw, "output", "default_format") or "xlsx", + report_no_time=cls._resolve_bool(raw, "report", "no_time"), email=cls._resolve_email(raw), ) @@ -344,6 +347,13 @@ class Config: return cls._app.output_default_format or "xlsx" return "xlsx" + @classmethod + def get_report_no_time(cls) -> bool: + """Возвращает report.no_time из YAML-конфига (по умолчанию False).""" + if cls._app: + return cls._app.report_no_time + return False + @classmethod def get_email_config(cls) -> "EmailConfig | None": """Возвращает EmailConfig из YAML-конфига или None, если не настроен.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index ed77ea3..0c172da 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -403,6 +403,21 @@ class TestInitConfig: assert data["redmine"]["url"] == "" assert data["redmine"]["api_key"] == "" + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + def test_init_config_includes_report_section(self, tmp_path): + """--init-config генерирует секцию report с no_time: false.""" + import yaml + + config_path = tmp_path / "config.yml" + code = main(["--init-config", "--config-path", str(config_path)]) + assert code == 0 + + with open(config_path) as fh: + data = yaml.safe_load(fh) + + assert "report" in data + assert data["report"]["no_time"] is False + @mock.patch.dict(os.environ, {}, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @@ -994,3 +1009,252 @@ class TestSendFlag: mock_fetch.return_value = None code = main(["--date", "2026-06-01--2026-06-30", "--send"]) assert code == 0 + + +class TestResolveNoTime: + """Tests for _resolve_no_time().""" + + def test_cli_flag_wins_in_manual_mode(self): + """CLI --no-time побеждает в ручном режиме.""" + from redmine_reporter.cli import _resolve_no_time + + assert _resolve_no_time(True, False) is True + + def test_cli_flag_wins_in_auto_mode(self): + """CLI --no-time побеждает в автоматическом режиме.""" + from redmine_reporter.cli import _resolve_no_time + + assert _resolve_no_time(True, True) is True + + @mock.patch("redmine_reporter.cli.Config.get_report_no_time", return_value=True) + def test_yaml_applies_in_auto_mode(self, mock_get): + """YAML report.no_time применяется в автоматическом режиме.""" + from redmine_reporter.cli import _resolve_no_time + + assert _resolve_no_time(False, True) is True + mock_get.assert_called_once() + + def test_default_in_auto_mode(self): + """Без YAML report.no_time в автоматическом режиме — False.""" + from redmine_reporter.cli import _resolve_no_time + + assert _resolve_no_time(False, True) is False + + @mock.patch("redmine_reporter.cli.Config.get_report_no_time", return_value=True) + def test_yaml_ignored_in_manual_mode(self, mock_get): + """YAML report.no_time игнорируется в ручном режиме --output.""" + from redmine_reporter.cli import _resolve_no_time + + assert _resolve_no_time(False, False) is False + mock_get.assert_not_called() + + +class TestReportNoTimeIntegration: + """Интеграционные тесты для report.no_time из YAML.""" + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_with_report_no_time_true(self, mock_save, mock_fetch, tmp_path): + """--commit с report.no_time: true передаёт no_time=True в форматтер.""" + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + config_path = tmp_path / "config.yml" + config_path.write_text( + yaml.dump( + { + "redmine": {"url": "https://x.com", "api_key": "token"}, + "report": {"no_time": True}, + "period": {"precision": "date", "dynamic": True}, + } + ) + ) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--commit", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + _, kwargs = mock_get.call_args + assert kwargs.get("no_time") is True + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_with_report_no_time_false(self, mock_save, mock_fetch, tmp_path): + """--commit без report.no_time передаёт no_time=False в форматтер.""" + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + config_path = tmp_path / "config.yml" + config_path.write_text( + yaml.dump( + { + "redmine": {"url": "https://x.com", "api_key": "token"}, + "report": {"no_time": False}, + "period": {"precision": "date", "dynamic": True}, + } + ) + ) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--commit", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + _, kwargs = mock_get.call_args + assert kwargs.get("no_time") is False + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_cli_no_time_overrides_yaml(self, mock_save, mock_fetch, tmp_path): + """--commit с --no-time побеждает report.no_time: false.""" + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + config_path = tmp_path / "config.yml" + config_path.write_text( + yaml.dump( + { + "redmine": {"url": "https://x.com", "api_key": "token"}, + "report": {"no_time": False}, + "period": {"precision": "date", "dynamic": True}, + } + ) + ) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--commit", + "--no-time", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + _, kwargs = mock_get.call_args + assert kwargs.get("no_time") is True + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_output_ignores_report_no_time_true(self, mock_fetch, tmp_path): + """--output (ручной режим) игнорирует report.no_time: true.""" + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + config_path = tmp_path / "config.yml" + config_path.write_text( + yaml.dump( + { + "redmine": {"url": "https://x.com", "api_key": "token"}, + "report": {"no_time": True}, + } + ) + ) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + _, kwargs = mock_get.call_args + assert kwargs.get("no_time") is False + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.send_report") + def test_send_with_report_no_time_true(self, mock_send, mock_fetch, tmp_path): + """--send с report.no_time: true передаёт no_time=True в форматтер.""" + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + config_path = tmp_path / "config.yml" + config_path.write_text( + yaml.dump( + { + "redmine": {"url": "https://x.com", "api_key": "token"}, + "report": {"no_time": True}, + "email": { + "smtp": { + "host": "smtp.example.com", + "port": 587, + "user": "bot", + "password": "secret", + }, + "from": "bot@example.com", + "to": ["boss@example.com"], + }, + } + ) + ) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--send", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + _, kwargs = mock_get.call_args + assert kwargs.get("no_time") is True diff --git a/tests/test_config.py b/tests/test_config.py index 8d6f38f..9620990 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -611,3 +611,43 @@ class TestGetEmailConfig: Config._app = AppConfig.from_yaml(yaml_path) assert Config.get_email_config() is None + + +class TestReportNoTime: + """Tests for Config.get_report_no_time().""" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_report_no_time_from_yaml(self): + """report.no_time: true загружается из YAML.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("report:\n no_time: true\n") + + Config._app = AppConfig.from_yaml(yaml_path) + assert Config.get_report_no_time() is True + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_report_no_time_defaults_to_false(self): + """Без YAML-конфига report.no_time по умолчанию False.""" + Config._app = None + assert Config.get_report_no_time() is False + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_report_no_time_from_yaml_false(self): + """report.no_time: false явно загружается из YAML.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("report:\n no_time: false\n") + + Config._app = AppConfig.from_yaml(yaml_path) + assert Config.get_report_no_time() is False + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_report_no_time_invalid_type_defaults_to_false(self): + """report.no_time со строковым значением приводится к False.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("report:\n no_time: 'invalid'\n") + + Config._app = AppConfig.from_yaml(yaml_path) + assert Config.get_report_no_time() is False