1 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
35fa585bd0 feat: add email confirmation prompt before sending report
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
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
2026-07-26 16:15:10 +07:00
4 changed files with 236 additions and 9 deletions

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "redmine-reporter" name = "redmine-reporter"
version = "1.11.1" version = "1.12.0"
description = "Redmine time-entry based issue reporter for internal use" description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md" readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]

View File

@@ -1 +1 @@
__version__ = "1.11.1" __version__ = "1.12.0"

View File

@@ -256,6 +256,19 @@ def _save_and_maybe_send(
) )
return 1 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: try:
send_report( send_report(
email_config, email_config,

View File

@@ -964,8 +964,12 @@ class TestSendFlag:
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension") @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 после сохранения.""" """--send с --output вызывает send_report после сохранения."""
mock_input.return_value = "y"
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)] mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock() 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.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension") @mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_without_output_saves_to_default_path( 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 сохраняет файл по шаблону, затем отправляет.""" """--send без --output сохраняет файл по шаблону, затем отправляет."""
mock_input.return_value = "y"
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)] mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock() 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.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension") @mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_smtp_error_reported_to_stderr( 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.""" """Ошибка SMTP выводится в stderr, exit code 1."""
mock_input.return_value = "y"
from redmine_reporter.client import RedmineAPIError from redmine_reporter.client import RedmineAPIError
issue = _MockIssue() issue = _MockIssue()
@@ -1118,10 +1126,12 @@ class TestSendFlag:
@mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension") @mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("redmine_reporter.cli.save_period_to_config") @mock.patch("redmine_reporter.cli.save_period_to_config")
@mock.patch("builtins.input")
def test_send_with_commit_works_together( 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 работают вместе без конфликтов.""" """--send и --commit работают вместе без конфликтов."""
mock_input.return_value = "y"
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)] mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock() 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.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension") @mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_passes_rows_to_send_report( 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.""" """--send передаёт rows в send_report для генерации HTML."""
mock_input.return_value = "y"
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)] mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock() 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.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension") @mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_html_false_does_not_require_html_part( 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-части.""" """При email.html: false письмо отправляется без HTML-части."""
mock_input.return_value = "y"
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)] mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
@@ -1462,8 +1476,12 @@ class TestReportNoTimeIntegration:
@mock.patch.dict(os.environ, VALID_ENV, clear=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.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report") @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 в форматтер.""" """--send с report.no_time: true передаёт no_time=True в форматтер."""
mock_input.return_value = "y"
import yaml import yaml
issue = _MockIssue() issue = _MockIssue()
@@ -1660,3 +1678,199 @@ class TestSanitizeErrorOutput:
assert "raw details" not in captured.err assert "raw details" not in captured.err
assert "raw details" not in caplog.text assert "raw details" not in caplog.text
assert "safe message" in captured.err 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]: ")