feat: auto-email sending via SMTP (--send flag)
Closes #45 - New redmine_reporter/mailer.py: SMTP email sending with {author}/{period} template substitution, MIME attachment with correct content-type per file extension - Config.get_email_config(): returns EmailConfig from YAML or None when not configured - CLI --send flag: sends report after generation, works with --output, --commit, or standalone (saves to template path) - 31 new tests (22 mailer + 6 CLI + 3 config) - 249/249 tests passing, ruff clean, mypy clean
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -85,6 +85,7 @@ secrets.json
|
|||||||
# Temporary files
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
docs/superpowers
|
||||||
|
|
||||||
# Just in case
|
# Just in case
|
||||||
.~*
|
.~*
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from . import __version__
|
|||||||
from .client import RedmineAPIError, fetch_issues_with_spent_time
|
from .client import RedmineAPIError, fetch_issues_with_spent_time
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .formatters.factory import get_console_formatter, get_formatter_by_extension
|
from .formatters.factory import get_console_formatter, get_formatter_by_extension
|
||||||
|
from .mailer import send_report
|
||||||
from .report_builder import build_grouped_report, calculate_summary
|
from .report_builder import build_grouped_report, calculate_summary
|
||||||
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
|
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
|
||||||
|
|
||||||
@@ -122,6 +123,79 @@ def _compute_dedup_cutoff() -> Optional[datetime]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _save_and_maybe_send(
|
||||||
|
rows,
|
||||||
|
output_arg: str,
|
||||||
|
author: str,
|
||||||
|
from_date: str,
|
||||||
|
to_date: str,
|
||||||
|
no_time: bool,
|
||||||
|
do_send: bool,
|
||||||
|
) -> int:
|
||||||
|
"""Сохраняет отчёт в файл и опционально отправляет по email.
|
||||||
|
|
||||||
|
Returns 0 on success, 1 on error.
|
||||||
|
"""
|
||||||
|
output_ext = os.path.splitext(output_arg)[1].lower()
|
||||||
|
|
||||||
|
if not output_ext:
|
||||||
|
print(
|
||||||
|
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
formatter = get_formatter_by_extension(
|
||||||
|
output_ext,
|
||||||
|
author=author,
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
no_time=no_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not formatter:
|
||||||
|
if output_ext == ".odt":
|
||||||
|
print(
|
||||||
|
"❌ odfpy is not installed. Install with: pip install odfpy",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
|
||||||
|
print(
|
||||||
|
f"❌ Неизвестный формат файла: {output_ext!r}. "
|
||||||
|
f"Поддерживаются: {known_exts}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
formatter.save(rows, output_arg)
|
||||||
|
print(f"✅ Report saved to {output_arg}")
|
||||||
|
except Exception as e:
|
||||||
|
fmt = output_ext.lstrip(".").upper()
|
||||||
|
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if do_send:
|
||||||
|
email_config = Config.get_email_config()
|
||||||
|
if email_config is None:
|
||||||
|
print(
|
||||||
|
"❌ Email не настроен. Добавьте секцию 'email' в конфиг "
|
||||||
|
"(~/.config/redmine-reporter/config.yml).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_report(email_config, output_arg, author, f"{from_date}--{to_date}")
|
||||||
|
print(f"📧 Report sent to {', '.join(email_config.to)}")
|
||||||
|
except RedmineAPIError as e:
|
||||||
|
print(f"❌ {e.message}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[List[str]] = None) -> int:
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="redmine-reporter",
|
prog="redmine-reporter",
|
||||||
@@ -200,6 +274,11 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Save used period as last_used in YAML config and auto-commit to file",
|
help="Save used period as last_used in YAML config and auto-commit to file",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--send",
|
||||||
|
action="store_true",
|
||||||
|
help="Send generated report via email after saving (requires email section in config)",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
# --init-config: обработка до всего остального
|
# --init-config: обработка до всего остального
|
||||||
@@ -215,6 +294,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
args.user_name,
|
args.user_name,
|
||||||
args.no_time,
|
args.no_time,
|
||||||
args.by_activity,
|
args.by_activity,
|
||||||
|
args.send,
|
||||||
]
|
]
|
||||||
if any(report_flags):
|
if any(report_flags):
|
||||||
print(
|
print(
|
||||||
@@ -312,6 +392,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
activity = key.split(":", 1)[1]
|
activity = key.split(":", 1)[1]
|
||||||
print(f" [{activity}]: {value}h", file=sys.stderr)
|
print(f" [{activity}]: {value}h", file=sys.stderr)
|
||||||
|
|
||||||
|
if args.output or args.commit:
|
||||||
if args.output:
|
if args.output:
|
||||||
output_arg = resolve_output_path(
|
output_arg = resolve_output_path(
|
||||||
args.output,
|
args.output,
|
||||||
@@ -322,55 +403,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
from_date=from_date,
|
from_date=from_date,
|
||||||
to_date=to_date,
|
to_date=to_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
if output_arg is None:
|
|
||||||
print(
|
|
||||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
output_ext = os.path.splitext(output_arg)[1].lower()
|
|
||||||
|
|
||||||
if not output_ext:
|
|
||||||
print(
|
|
||||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
formatter = get_formatter_by_extension(
|
|
||||||
output_ext,
|
|
||||||
author=Config.get_author(args.author),
|
|
||||||
from_date=from_date,
|
|
||||||
to_date=to_date,
|
|
||||||
no_time=args.no_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not formatter:
|
|
||||||
if output_ext == ".odt":
|
|
||||||
print(
|
|
||||||
"❌ odfpy is not installed. Install with: pip install odfpy",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
|
# --commit без --output: используем default_format
|
||||||
print(
|
|
||||||
f"❌ Неизвестный формат файла: {output_ext!r}. "
|
|
||||||
f"Поддерживаются: {known_exts}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
formatter.save(rows, output_arg)
|
|
||||||
print(f"✅ Report saved to {output_arg}")
|
|
||||||
except Exception as e:
|
|
||||||
fmt = output_ext.lstrip(".").upper()
|
|
||||||
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
elif args.commit:
|
|
||||||
default_format = Config.get_output_default_format()
|
default_format = Config.get_output_default_format()
|
||||||
output_arg = resolve_output_path(
|
output_arg = resolve_output_path(
|
||||||
default_format,
|
default_format,
|
||||||
@@ -386,25 +420,46 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
|
print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
output_ext = os.path.splitext(output_arg)[1].lower()
|
ret = _save_and_maybe_send(
|
||||||
formatter = get_formatter_by_extension(
|
rows,
|
||||||
output_ext,
|
output_arg,
|
||||||
author=Config.get_author(args.author),
|
author=Config.get_author(args.author),
|
||||||
from_date=from_date,
|
from_date=from_date,
|
||||||
to_date=to_date,
|
to_date=to_date,
|
||||||
no_time=args.no_time,
|
no_time=args.no_time,
|
||||||
|
do_send=args.send,
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
return ret
|
||||||
|
|
||||||
|
elif args.send:
|
||||||
|
# --send без --output и --commit: сохраняем по шаблону и отправляем
|
||||||
|
default_format = Config.get_output_default_format()
|
||||||
|
output_arg = resolve_output_path(
|
||||||
|
default_format,
|
||||||
|
output_dir=Config.get_output_dir(),
|
||||||
|
filename_template=Config.get_output_filename(),
|
||||||
|
default_format=default_format,
|
||||||
|
author=Config.get_author(args.author),
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not formatter:
|
if output_arg is None:
|
||||||
print(f"❌ Не удалось создать форматтер для {output_ext}", file=sys.stderr)
|
print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
ret = _save_and_maybe_send(
|
||||||
formatter.save(rows, output_arg)
|
rows,
|
||||||
print(f"✅ Report saved to {output_arg}")
|
output_arg,
|
||||||
except Exception as e:
|
author=Config.get_author(args.author),
|
||||||
print(f"❌ Export error: {e}", file=sys.stderr)
|
from_date=from_date,
|
||||||
return 1
|
to_date=to_date,
|
||||||
|
no_time=args.no_time,
|
||||||
|
do_send=True,
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
return ret
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if args.compact:
|
if args.compact:
|
||||||
|
|||||||
@@ -343,6 +343,16 @@ class Config:
|
|||||||
return cls._app.output_default_format or "xlsx"
|
return cls._app.output_default_format or "xlsx"
|
||||||
return "xlsx"
|
return "xlsx"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_email_config(cls) -> "EmailConfig | None":
|
||||||
|
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""
|
||||||
|
if cls._app is None:
|
||||||
|
return None
|
||||||
|
email = cls._app.email
|
||||||
|
if not email.smtp.host:
|
||||||
|
return None
|
||||||
|
return email
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_default_date_range(cls) -> str:
|
def get_default_date_range(cls) -> str:
|
||||||
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
|
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
|
||||||
|
|||||||
120
redmine_reporter/mailer.py
Normal file
120
redmine_reporter/mailer.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Отправка сгенерированного отчёта по email через SMTP."""
|
||||||
|
|
||||||
|
import email.charset as _charset
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
from email.mime.application import MIMEApplication
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from .client import RedmineAPIError
|
||||||
|
from .config import EmailConfig
|
||||||
|
|
||||||
|
# Use 8bit transfer encoding for UTF-8 so non-ASCII text (e.g. Russian)
|
||||||
|
# appears literally in MIME output instead of base64.
|
||||||
|
_charset.add_charset("utf-8", _charset.SHORTEST, None, "utf-8")
|
||||||
|
|
||||||
|
SMTP_TIMEOUT = 30
|
||||||
|
|
||||||
|
MIME_TYPES: Dict[str, str] = {
|
||||||
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".odt": "application/vnd.oasis.opendocument.text",
|
||||||
|
".csv": "text/csv",
|
||||||
|
".html": "text/html",
|
||||||
|
".json": "application/json",
|
||||||
|
".md": "text/markdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_mime_type(file_path: str) -> str:
|
||||||
|
"""Определяет MIME-тип по расширению файла."""
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
return MIME_TYPES.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(
|
||||||
|
email_config: EmailConfig,
|
||||||
|
file_path: str,
|
||||||
|
author: str,
|
||||||
|
period: str,
|
||||||
|
) -> MIMEMultipart:
|
||||||
|
"""Формирует MIME-письмо с подстановками и вложением."""
|
||||||
|
subject = email_config.subject.replace("{author}", author).replace("{period}", period)
|
||||||
|
body = email_config.body_text.replace("{author}", author).replace("{period}", period)
|
||||||
|
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = email_config.from_
|
||||||
|
msg["To"] = ", ".join(email_config.to)
|
||||||
|
if email_config.cc:
|
||||||
|
msg["Cc"] = ", ".join(email_config.cc)
|
||||||
|
|
||||||
|
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
|
||||||
|
if email_config.attach:
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as fh:
|
||||||
|
attachment = MIMEApplication(fh.read())
|
||||||
|
except OSError:
|
||||||
|
raise RedmineAPIError(
|
||||||
|
f"Не удалось прочитать файл отчёта: {file_path}"
|
||||||
|
) from None
|
||||||
|
attachment.add_header(
|
||||||
|
"Content-Disposition",
|
||||||
|
"attachment",
|
||||||
|
filename=os.path.basename(file_path),
|
||||||
|
)
|
||||||
|
mime_type = _resolve_mime_type(file_path)
|
||||||
|
attachment.set_type(mime_type)
|
||||||
|
msg.attach(attachment)
|
||||||
|
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def send_report(
|
||||||
|
email_config: EmailConfig,
|
||||||
|
file_path: str,
|
||||||
|
author: str,
|
||||||
|
period: str,
|
||||||
|
) -> None:
|
||||||
|
"""Отправляет сгенерированный отчёт по email через SMTP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_config: Настройки SMTP и письма.
|
||||||
|
file_path: Путь к файлу отчёта для вложения.
|
||||||
|
author: Имя автора (для подстановки в тему/тело).
|
||||||
|
period: Строка периода (для подстановки в тему/тело).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RedmineAPIError: При любой ошибке соединения или отправки.
|
||||||
|
"""
|
||||||
|
smtp_cfg = email_config.smtp
|
||||||
|
msg = build_message(email_config, file_path, author, period)
|
||||||
|
all_recipients = list(email_config.to) + list(email_config.cc) + list(email_config.bcc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=SMTP_TIMEOUT) as server:
|
||||||
|
if smtp_cfg.tls:
|
||||||
|
server.starttls()
|
||||||
|
if smtp_cfg.user:
|
||||||
|
server.login(smtp_cfg.user, smtp_cfg.password)
|
||||||
|
|
||||||
|
server.send_message(msg, from_addr=email_config.from_, to_addrs=all_recipients)
|
||||||
|
except smtplib.SMTPAuthenticationError:
|
||||||
|
raise RedmineAPIError(
|
||||||
|
"Ошибка аутентификации SMTP. Проверьте логин и пароль."
|
||||||
|
) from None
|
||||||
|
except TimeoutError:
|
||||||
|
raise RedmineAPIError(
|
||||||
|
"Таймаут соединения с SMTP-сервером."
|
||||||
|
) from None
|
||||||
|
except smtplib.SMTPException as exc:
|
||||||
|
raise RedmineAPIError(
|
||||||
|
f"Ошибка отправки письма: {exc}"
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise RedmineAPIError(
|
||||||
|
f"Не удалось подключиться к SMTP-серверу {smtp_cfg.host}:{smtp_cfg.port}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
@@ -765,3 +765,199 @@ class TestCommitFlag:
|
|||||||
)
|
)
|
||||||
assert code == 0
|
assert code == 0
|
||||||
mock_save.assert_not_called()
|
mock_save.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #45: --send tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendFlag:
|
||||||
|
"""Tests for --send flag."""
|
||||||
|
|
||||||
|
@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")
|
||||||
|
def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path):
|
||||||
|
"""--send с --output вызывает send_report после сохранения."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
|
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_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")
|
||||||
|
def test_send_without_output_saves_to_default_path(self, mock_get, mock_send, mock_fetch, tmp_path):
|
||||||
|
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
|
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:\n"
|
||||||
|
" dir: " + str(tmp_path / "reports") + "\n"
|
||||||
|
" filename: report_{date}.{ext}\n"
|
||||||
|
" default_format: xlsx\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date", "2026-06-01--2026-06-30",
|
||||||
|
"--send",
|
||||||
|
"--config-path", str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_formatter.save.assert_called_once()
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_send_without_email_config_is_error(self, mock_fetch, tmp_path, capsys):
|
||||||
|
"""--send без email-конфига — ошибка и выход 1."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text("redmine:\n url: https://x.com\n api_key: token\n")
|
||||||
|
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date", "2026-06-01--2026-06-30",
|
||||||
|
"--output", str(tmp_path / "report.xlsx"),
|
||||||
|
"--send",
|
||||||
|
"--config-path", str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 1
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Email не настроен" in captured.err
|
||||||
|
|
||||||
|
@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")
|
||||||
|
def test_send_smtp_error_reported_to_stderr(self, mock_get, mock_send, mock_fetch, tmp_path, capsys):
|
||||||
|
"""Ошибка SMTP выводится в stderr, exit code 1."""
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
mock_send.side_effect = RedmineAPIError("Не удалось подключиться к SMTP-серверу bad:587")
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"email:\n"
|
||||||
|
" smtp:\n"
|
||||||
|
" host: bad\n"
|
||||||
|
" port: 587\n"
|
||||||
|
" user: bot\n"
|
||||||
|
" password: secret\n"
|
||||||
|
" from: bot@example.com\n"
|
||||||
|
" to:\n"
|
||||||
|
" - boss@example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date", "2026-06-01--2026-06-30",
|
||||||
|
"--output", str(tmp_path / "report.xlsx"),
|
||||||
|
"--send",
|
||||||
|
"--config-path", str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 1
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "SMTP" in captured.err
|
||||||
|
|
||||||
|
@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("redmine_reporter.cli.save_period_to_config")
|
||||||
|
def test_send_with_commit_works_together(
|
||||||
|
self, mock_save, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""--send и --commit работают вместе без конфликтов."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
|
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"
|
||||||
|
"period:\n"
|
||||||
|
" precision: date\n"
|
||||||
|
" dynamic: true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date", "2026-06-01--2026-06-30",
|
||||||
|
"--output", str(tmp_path / "report.xlsx"),
|
||||||
|
"--send",
|
||||||
|
"--commit",
|
||||||
|
"--config-path", str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_send_no_entries_exits_early(self, mock_fetch):
|
||||||
|
"""--send без time entries просто выходит с 0."""
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
code = main(["--date", "2026-06-01--2026-06-30", "--send"])
|
||||||
|
assert code == 0
|
||||||
|
|||||||
@@ -557,3 +557,53 @@ class TestDefaultDateRangeWithLastUsed:
|
|||||||
Config._app = AppConfig.from_yaml(yaml_path)
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
result = Config.get_default_date_range()
|
result = Config.get_default_date_range()
|
||||||
assert result == "2026-04-01--2026-04-15"
|
assert result == "2026-04-01--2026-04-15"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetEmailConfig:
|
||||||
|
"""Tests for Config.get_email_config()."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_get_email_config_from_yaml(self):
|
||||||
|
"""get_email_config() возвращает EmailConfig из YAML."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"email:\n"
|
||||||
|
" smtp:\n"
|
||||||
|
" host: smtp.example.com\n"
|
||||||
|
" port: 587\n"
|
||||||
|
" user: bot@example.com\n"
|
||||||
|
" password: secret\n"
|
||||||
|
" tls: true\n"
|
||||||
|
" from: bot@example.com\n"
|
||||||
|
" to:\n"
|
||||||
|
" - boss@example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
cfg = Config.get_email_config()
|
||||||
|
|
||||||
|
assert cfg is not None
|
||||||
|
assert cfg.smtp.host == "smtp.example.com"
|
||||||
|
assert cfg.smtp.port == 587
|
||||||
|
assert cfg.smtp.user == "bot@example.com"
|
||||||
|
assert cfg.smtp.password == "secret"
|
||||||
|
assert cfg.smtp.tls is True
|
||||||
|
assert cfg.from_ == "bot@example.com"
|
||||||
|
assert cfg.to == ["boss@example.com"]
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_get_email_config_returns_none_when_no_yaml(self):
|
||||||
|
"""Без YAML-конфига get_email_config() возвращает None."""
|
||||||
|
Config._app = None
|
||||||
|
assert Config.get_email_config() is None
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_get_email_config_returns_none_when_no_host(self):
|
||||||
|
"""С YAML но без smtp.host — возвращает None."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("email:\n smtp:\n host: ''\n")
|
||||||
|
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert Config.get_email_config() is None
|
||||||
|
|||||||
198
tests/test_mailer.py
Normal file
198
tests/test_mailer.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import smtplib
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
from redmine_reporter.config import EmailConfig, SmtpConfig
|
||||||
|
from redmine_reporter.mailer import build_message, send_report
|
||||||
|
|
||||||
|
|
||||||
|
def _make_email_config(**overrides) -> EmailConfig:
|
||||||
|
"""Создаёт EmailConfig с минимальными валидными настройками."""
|
||||||
|
smtp = SmtpConfig(
|
||||||
|
host=overrides.pop("smtp_host", "smtp.example.com"),
|
||||||
|
port=overrides.pop("smtp_port", 587),
|
||||||
|
user=overrides.pop("smtp_user", "bot@example.com"),
|
||||||
|
password=overrides.pop("smtp_password", "secret"),
|
||||||
|
tls=overrides.pop("smtp_tls", True),
|
||||||
|
)
|
||||||
|
return EmailConfig(
|
||||||
|
smtp=smtp,
|
||||||
|
from_=overrides.pop("from_", "bot@example.com"),
|
||||||
|
to=overrides.pop("to", ["boss@example.com"]),
|
||||||
|
cc=overrides.pop("cc", []),
|
||||||
|
bcc=overrides.pop("bcc", []),
|
||||||
|
subject=overrides.pop("subject", "Отчёт {author} за {period}"),
|
||||||
|
body_text=overrides.pop("body_text", "Во вложении отчёт."),
|
||||||
|
attach=overrides.pop("attach", True),
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildMessage:
|
||||||
|
"""Тесты формирования MIME-письма."""
|
||||||
|
|
||||||
|
def test_subject_substitution(self):
|
||||||
|
"""{author} и {period} подставляются в тему."""
|
||||||
|
cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False)
|
||||||
|
msg = build_message(cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30")
|
||||||
|
assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30"
|
||||||
|
|
||||||
|
def test_body_substitution(self):
|
||||||
|
"""{author} и {period} подставляются в тело."""
|
||||||
|
cfg = _make_email_config(body_text="Автор: {author}, период: {period}", attach=False)
|
||||||
|
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1")
|
||||||
|
plain_part = msg.get_payload()[0]
|
||||||
|
assert "Автор: Иванов, период: Q1" in plain_part.as_string()
|
||||||
|
|
||||||
|
def test_from_header(self):
|
||||||
|
cfg = _make_email_config(from_="sender@example.com", attach=False)
|
||||||
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P")
|
||||||
|
assert msg["From"] == "sender@example.com"
|
||||||
|
|
||||||
|
def test_to_header(self):
|
||||||
|
cfg = _make_email_config(to=["a@x.com", "b@x.com"], attach=False)
|
||||||
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P")
|
||||||
|
assert msg["To"] == "a@x.com, b@x.com"
|
||||||
|
|
||||||
|
def test_cc_header(self):
|
||||||
|
cfg = _make_email_config(cc=["cc@x.com"], attach=False)
|
||||||
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P")
|
||||||
|
assert msg["Cc"] == "cc@x.com"
|
||||||
|
|
||||||
|
def test_bcc_not_in_headers(self):
|
||||||
|
"""BCC не должен появляться в заголовках письма."""
|
||||||
|
cfg = _make_email_config(bcc=["hidden@x.com"], attach=False)
|
||||||
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P")
|
||||||
|
assert "Bcc" not in msg
|
||||||
|
|
||||||
|
def test_attachment_present_when_attach_true(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake xlsx content")
|
||||||
|
cfg = _make_email_config(attach=True)
|
||||||
|
msg = build_message(cfg, str(report), "A", "P")
|
||||||
|
assert len(msg.get_payload()) == 2
|
||||||
|
|
||||||
|
def test_no_attachment_when_attach_false(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake xlsx content")
|
||||||
|
cfg = _make_email_config(attach=False)
|
||||||
|
msg = build_message(cfg, str(report), "A", "P")
|
||||||
|
assert len(msg.get_payload()) == 1
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ext,expected_mime", [
|
||||||
|
(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
|
||||||
|
(".odt", "application/vnd.oasis.opendocument.text"),
|
||||||
|
(".csv", "text/csv"),
|
||||||
|
(".html", "text/html"),
|
||||||
|
(".json", "application/json"),
|
||||||
|
(".md", "text/markdown"),
|
||||||
|
])
|
||||||
|
def test_mime_type_by_extension(self, ext, expected_mime, tmp_path):
|
||||||
|
report = tmp_path / f"report{ext}"
|
||||||
|
report.write_text("content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
msg = build_message(cfg, str(report), "A", "P")
|
||||||
|
attachment_part = msg.get_payload()[1]
|
||||||
|
assert attachment_part.get_content_type() == expected_mime
|
||||||
|
|
||||||
|
def test_missing_attachment_file_raises_error(self, tmp_path):
|
||||||
|
"""Если файл вложения не существует — RedmineAPIError."""
|
||||||
|
cfg = _make_email_config()
|
||||||
|
missing = str(tmp_path / "nonexistent.xlsx")
|
||||||
|
with pytest.raises(RedmineAPIError, match="Не удалось прочитать файл"):
|
||||||
|
build_message(cfg, missing, "A", "P")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendReport:
|
||||||
|
"""Тесты отправки письма."""
|
||||||
|
|
||||||
|
def test_send_success(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
|
mock_smtp = mock.MagicMock()
|
||||||
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
|
||||||
|
send_report(cfg, str(report), "Кокос А.Н.", "2026-06-01--2026-06-30")
|
||||||
|
|
||||||
|
mock_smtp_class.assert_called_once_with("smtp.example.com", 587, timeout=30)
|
||||||
|
mock_smtp.starttls.assert_called_once()
|
||||||
|
mock_smtp.login.assert_called_once_with("bot@example.com", "secret")
|
||||||
|
mock_smtp.send_message.assert_called_once()
|
||||||
|
|
||||||
|
def test_send_no_tls(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(smtp_tls=False)
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
|
mock_smtp = mock.MagicMock()
|
||||||
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
|
||||||
|
send_report(cfg, str(report), "A", "P")
|
||||||
|
|
||||||
|
mock_smtp.starttls.assert_not_called()
|
||||||
|
|
||||||
|
def test_send_connection_error(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")):
|
||||||
|
with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"):
|
||||||
|
send_report(cfg, str(report), "A", "P")
|
||||||
|
|
||||||
|
def test_send_auth_error(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
|
mock_smtp = mock.MagicMock()
|
||||||
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
mock_smtp.login.side_effect = smtplib.SMTPAuthenticationError(535, b"Bad auth")
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"):
|
||||||
|
send_report(cfg, str(report), "A", "P")
|
||||||
|
|
||||||
|
def test_send_timeout_error(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP", side_effect=TimeoutError()):
|
||||||
|
with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"):
|
||||||
|
send_report(cfg, str(report), "A", "P")
|
||||||
|
|
||||||
|
def test_send_generic_smtp_error(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
|
mock_smtp = mock.MagicMock()
|
||||||
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
mock_smtp.send_message.side_effect = smtplib.SMTPException("Something went wrong")
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"):
|
||||||
|
send_report(cfg, str(report), "A", "P")
|
||||||
|
|
||||||
|
def test_send_includes_cc_and_bcc(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(to=["a@x.com"], cc=["cc@x.com"], bcc=["bcc@x.com"])
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
|
mock_smtp = mock.MagicMock()
|
||||||
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
|
||||||
|
send_report(cfg, str(report), "A", "P")
|
||||||
|
|
||||||
|
call_args = mock_smtp.send_message.call_args
|
||||||
|
msg = call_args.args[0]
|
||||||
|
assert msg["To"] == "a@x.com"
|
||||||
|
assert msg["Cc"] == "cc@x.com"
|
||||||
Reference in New Issue
Block a user