3 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
db069dfb4b chore: bump version to 1.12.1
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
2026-09-15 15:43:11 +07:00
Кокос Артем Николаевич
4a9a21624d fix: sliding period window for datetime precision
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
After --commit with precision=datetime, last_used held from==to==now,
so the next default period degenerated to a single second and showed
no new time entries. Now --commit stores the real report window start
and get_default_date_range() returns last_used.to..now for
datetime+dynamic mode.

Closes #70
2026-09-15 15:40:20 +07:00
Кокос Артем Николаевич
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
6 changed files with 352 additions and 34 deletions

View File

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

View File

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

View File

@@ -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,
@@ -567,10 +580,9 @@ def main(argv: Optional[List[str]] = None) -> int:
dynamic = Config._app.period_dynamic if Config._app else False
if precision == "datetime":
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
# метки aware cutoff для дедупликации.
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
from_str = now
# from -- фактическое начало окна отчёта; to -- дедуп-маркер (naive UTC).
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
from_str = from_date
to_str = now
else:
from_str = from_date

View File

@@ -2,7 +2,7 @@ import logging
import os
import sys
from dataclasses import dataclass, field
from datetime import date, timedelta
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, Union
@@ -440,6 +440,23 @@ class Config:
if from_env:
return f"{from_env}--{to_env or today_str}"
if (
cls._app
and cls._app.period_dynamic
and (cls._app.period_precision or "date") == "datetime"
and cls._app.period_last_used_to
):
last_to = datetime.fromisoformat(
cls._app.period_last_used_to.replace("Z", "+00:00")
)
if last_to.tzinfo is None:
last_to = last_to.replace(tzinfo=timezone.utc)
else:
last_to = last_to.astimezone(timezone.utc)
now_utc = datetime.now(timezone.utc)
fmt = "%Y-%m-%dT%H:%M:%S"
return f"{last_to.strftime(fmt)}--{now_utc.strftime(fmt)}"
if (
cls._app
and cls._app.period_dynamic

View File

@@ -715,7 +715,9 @@ class TestCommitFlag:
def test_commit_with_precision_datetime_passes_datetime_strings(
self, mock_save, mock_fetch, tmp_path
):
"""При precision=datetime --commit сохраняет timestamps."""
"""При precision=datetime --commit сохраняет from окна и to в naive UTC."""
from datetime import datetime
import yaml
issue = _MockIssue()
@@ -732,7 +734,7 @@ class TestCommitFlag:
code = main(
[
"--date",
"2026-06-30--2026-06-30",
"2026-09-10T09:00:00--2026-09-12T18:00:00",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
@@ -745,17 +747,19 @@ class TestCommitFlag:
call_args = mock_save.call_args
assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2]
assert "T" in saved_from
assert "T" in saved_to
assert saved_from == "2026-09-10T09:00:00"
assert datetime.strptime(saved_to, "%Y-%m-%dT%H:%M:%S")
assert call_args.args[3] == "datetime"
assert call_args.args[4] 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_precision_datetime_saves_aware_utc(
def test_commit_with_precision_datetime_saves_naive_utc_to(
self, mock_save, mock_fetch, tmp_path
):
"""При precision=datetime --commit сохраняет last_used как aware UTC (#58)."""
from datetime import datetime, timezone
"""При precision=datetime --commit сохраняет to как naive UTC (#70)."""
from datetime import datetime
import yaml
@@ -773,7 +777,7 @@ class TestCommitFlag:
code = main(
[
"--date",
"2026-06-30--2026-06-30",
"2026-09-10T09:00:00--2026-09-12T18:00:00",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
@@ -786,10 +790,60 @@ class TestCommitFlag:
call_args = mock_save.call_args
assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2]
for saved in (saved_from, saved_to):
parsed = datetime.fromisoformat(saved)
assert parsed.tzinfo is not None, f"{saved} must be timezone-aware"
assert parsed.utcoffset() == timezone.utc.utcoffset(None)
assert saved_from == "2026-09-10T09:00:00"
parsed = datetime.fromisoformat(saved_to)
assert parsed.tzinfo is None, f"{saved_to} must be naive UTC"
@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_datetime_persists_window_start_and_now(
self, mock_save, mock_fetch, tmp_path
):
"""--commit при datetime сохраняет начало окна и текущий naive UTC (#70)."""
from datetime import datetime as real_datetime
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
)
fake_datetime = mock.MagicMock()
fake_datetime.fromisoformat = real_datetime.fromisoformat
fake_datetime.strptime = real_datetime.strptime
fake_datetime.now.return_value = real_datetime(2026, 9, 15, 12, 0, 0)
with mock.patch("redmine_reporter.cli.datetime", fake_datetime):
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(
[
"--commit",
"--date",
"2026-09-10T09:00:00--2026-09-12T18:00:00",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path),
"2026-09-10T09:00:00",
"2026-09-15T12:00:00",
"datetime",
True,
)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@@ -964,8 +1018,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 +1061,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 +1131,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 +1180,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 +1239,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 +1286,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 +1530,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()
@@ -1517,11 +1589,10 @@ class TestReportNoTimeIntegration:
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
"""dynamic+datetime без --date: datetime-диапазон из last_used принимается.
"""dynamic+datetime без --date: скользящее окно от last_used.to до now (#70).
Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон
с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used,
отчёт строится без ошибки.
Ожидаемое поведение после фикса #70: дефолтный диапазон начинается с
last_used.to и заканчивается текущим моментом, а не сдвинутым периодом.
"""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
@@ -1536,14 +1607,17 @@ def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
" to: '2026-06-30T23:59:59'\n"
)
code = main(["--config-path", str(config_path)])
real_dt = __import__("datetime").datetime
with mock.patch("redmine_reporter.config.datetime") as dt:
dt.fromisoformat = real_dt.fromisoformat
dt.now.return_value = real_dt(2026, 7, 15, 10, 0, 0)
code = main(["--config-path", str(config_path)])
assert code == 0
args, _ = mock_fetch.call_args
from_date, to_date = args[0], args[1]
# Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59
assert from_date == "2026-07-01T00:00:00"
assert to_date == "2026-07-30T23:59:59"
assert from_date == "2026-06-30T23:59:59"
assert to_date == "2026-07-15T10:00:00"
# ---------------------------------------------------------------------------
@@ -1660,3 +1734,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]: ")

View File

@@ -99,6 +99,25 @@ def test_get_default_date_range_fallback():
assert result == f"{start.isoformat()}--{today.isoformat()}"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_default_date_range_datetime_dynamic_sliding_window():
app = AppConfig(
period_precision="datetime",
period_dynamic=True,
period_last_used_to="2026-09-10T15:00:00+00:00",
)
Config._app = app
try:
real_dt = __import__("datetime").datetime
with mock.patch("redmine_reporter.config.datetime") as dt:
dt.fromisoformat = real_dt.fromisoformat
dt.now.return_value = real_dt(2026, 9, 15, 10, 0, 0)
rng = Config.get_default_date_range()
finally:
Config._app = None
assert rng == "2026-09-10T15:00:00--2026-09-15T10:00:00"
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) --