From 35fa585bd074a745ecc9f3f6bf4469da447dfaad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Sun, 26 Jul 2026 16:15:10 +0700 Subject: [PATCH] feat: add email confirmation prompt before sending report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require user confirmation (y/N) before sending email via --send. Shows from/to/cc/bcc/file info. Skips send on anything except y/yes. Version bump: 1.11.1 → 1.12.0 --- pyproject.toml | 2 +- redmine_reporter/__init__.py | 2 +- redmine_reporter/cli.py | 13 ++ tests/test_cli.py | 228 +++++++++++++++++++++++++++++++++-- 4 files changed, 236 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ce5abe3..e4fbcb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "redmine-reporter" -version = "1.11.1" +version = "1.12.0" description = "Redmine time-entry based issue reporter for internal use" readme = "README.md" authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] diff --git a/redmine_reporter/__init__.py b/redmine_reporter/__init__.py index c3fa782..b518f6e 100644 --- a/redmine_reporter/__init__.py +++ b/redmine_reporter/__init__.py @@ -1 +1 @@ -__version__ = "1.11.1" +__version__ = "1.12.0" diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index ead848e..59090bf 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -256,6 +256,19 @@ def _save_and_maybe_send( ) return 1 + print("\n📧 Готово к отправке:") + print(f" From: {email_config.from_}") + print(f" To: {', '.join(email_config.to)}") + if email_config.cc: + print(f" Cc: {', '.join(email_config.cc)}") + if email_config.bcc: + print(f" Bcc: {', '.join(email_config.bcc)}") + print(f" File: {output_arg}") + response = input("Отправить? [y/N]: ").strip().lower() + if response not in ("y", "yes"): + print("⏭️ Отправка отменена.") + return 0 + try: send_report( email_config, diff --git a/tests/test_cli.py b/tests/test_cli.py index 94157ce..5020f54 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -964,8 +964,12 @@ class TestSendFlag: @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.get_formatter_by_extension") - def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path): + @mock.patch("builtins.input") + def test_send_triggers_mailer( + self, mock_input, mock_get, mock_send, mock_fetch, tmp_path + ): """--send с --output вызывает send_report после сохранения.""" + mock_input.return_value = "y" issue = _MockIssue() mock_fetch.return_value = [(issue, 1.0, None)] mock_formatter = mock.MagicMock() @@ -1003,10 +1007,12 @@ class TestSendFlag: @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("builtins.input") def test_send_without_output_saves_to_default_path( - self, mock_get, mock_send, mock_fetch, tmp_path + self, mock_input, mock_get, mock_send, mock_fetch, tmp_path ): """--send без --output сохраняет файл по шаблону, затем отправляет.""" + mock_input.return_value = "y" issue = _MockIssue() mock_fetch.return_value = [(issue, 1.0, None)] mock_formatter = mock.MagicMock() @@ -1071,10 +1077,12 @@ class TestSendFlag: @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("builtins.input") def test_send_smtp_error_reported_to_stderr( - self, mock_get, mock_send, mock_fetch, tmp_path, capsys + self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys ): """Ошибка SMTP выводится в stderr, exit code 1.""" + mock_input.return_value = "y" from redmine_reporter.client import RedmineAPIError issue = _MockIssue() @@ -1118,10 +1126,12 @@ class TestSendFlag: @mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.get_formatter_by_extension") @mock.patch("redmine_reporter.cli.save_period_to_config") + @mock.patch("builtins.input") def test_send_with_commit_works_together( - self, mock_save, mock_get, mock_send, mock_fetch, tmp_path + self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path ): """--send и --commit работают вместе без конфликтов.""" + mock_input.return_value = "y" issue = _MockIssue() mock_fetch.return_value = [(issue, 1.0, None)] mock_formatter = mock.MagicMock() @@ -1175,10 +1185,12 @@ class TestSendHtmlBody: @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("builtins.input") def test_send_passes_rows_to_send_report( - self, mock_get, mock_send, mock_fetch, tmp_path + self, mock_input, mock_get, mock_send, mock_fetch, tmp_path ): """--send передаёт rows в send_report для генерации HTML.""" + mock_input.return_value = "y" issue = _MockIssue() mock_fetch.return_value = [(issue, 1.0, None)] mock_formatter = mock.MagicMock() @@ -1220,10 +1232,12 @@ class TestSendHtmlBody: @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("builtins.input") def test_send_html_false_does_not_require_html_part( - self, mock_get, mock_send, mock_fetch, tmp_path + self, mock_input, mock_get, mock_send, mock_fetch, tmp_path ): """При email.html: false письмо отправляется без HTML-части.""" + mock_input.return_value = "y" issue = _MockIssue() mock_fetch.return_value = [(issue, 1.0, None)] mock_formatter = mock.MagicMock() @@ -1462,8 +1476,12 @@ class TestReportNoTimeIntegration: @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): + @mock.patch("builtins.input") + def test_send_with_report_no_time_true( + self, mock_input, mock_send, mock_fetch, tmp_path + ): """--send с report.no_time: true передаёт no_time=True в форматтер.""" + mock_input.return_value = "y" import yaml issue = _MockIssue() @@ -1660,3 +1678,199 @@ class TestSanitizeErrorOutput: assert "raw details" not in captured.err assert "raw details" not in caplog.text assert "safe message" in captured.err + + +# --------------------------------------------------------------------------- +# Email confirmation prompt before --send +# --------------------------------------------------------------------------- + + +class TestSendConfirmation: + """Tests for email confirmation prompt before sending.""" + + @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") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("builtins.input") + def test_send_confirmation_yes_calls_send_report( + self, mock_input, mock_get, mock_send, mock_fetch, tmp_path + ): + """Подтверждение 'y' — send_report вызывается.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + mock_input.return_value = "y" + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + " cc:\n" + " - cc@example.com\n" + " bcc:\n" + " - bcc@example.com\n" + ) + + output = str(tmp_path / "report.xlsx") + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--output", + output, + "--send", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + mock_send.assert_called_once() + + @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") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("builtins.input") + def test_send_confirmation_no_skips_send( + self, mock_input, mock_get, mock_send, mock_fetch, tmp_path + ): + """Ответ 'n' — send_report не вызывается.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + mock_input.return_value = "n" + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + ) + + output = str(tmp_path / "report.xlsx") + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--output", + output, + "--send", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + mock_send.assert_not_called() + + @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") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("builtins.input") + def test_send_confirmation_default_skips_send( + self, mock_input, mock_get, mock_send, mock_fetch, tmp_path + ): + """Пустой ввод (Enter) — send_report не вызывается.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + mock_input.return_value = "" + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + ) + + output = str(tmp_path / "report.xlsx") + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--output", + output, + "--send", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + mock_send.assert_not_called() + + @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") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("builtins.input") + def test_send_confirmation_shows_recipients( + self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys + ): + """Подтверждение показывает from, to, cc, bcc, file.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + mock_input.return_value = "y" + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: sender@example.com\n" + " to:\n" + " - boss@example.com\n" + " cc:\n" + " - cc@example.com\n" + " bcc:\n" + " - bcc@example.com\n" + ) + + output = str(tmp_path / "report.xlsx") + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--output", + output, + "--send", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + captured = capsys.readouterr() + assert "sender@example.com" in captured.out + assert "boss@example.com" in captured.out + assert "cc@example.com" in captured.out + assert "bcc@example.com" in captured.out + assert output in captured.out + mock_input.assert_called_once_with("Отправить? [y/N]: ")