feat: add HTML email body for --send (#48)
Add email.html config flag (default false). When enabled, --send includes an HTML version of the report body generated via HTMLFormatter alongside the plain-text part in a multipart/alternative message. - EmailConfig gains html: bool field - mailer.build_message/send_report accept rows for HTML generation - CLI passes rows to send_report - --init-config generates email.html: false - README.md and docs/CONFIG.md updated Bump version to 1.10.0. Closes #48
This commit is contained in:
@@ -21,6 +21,7 @@ CLI-инструмент для генерации отчётов по зада
|
|||||||
- Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание.
|
- Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание.
|
||||||
- `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска.
|
- `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска.
|
||||||
- `--send`: отправка отчёта по email через SMTP сразу после генерации.
|
- `--send`: отправка отчёта по email через SMTP сразу после генерации.
|
||||||
|
- HTML-версия тела письма при `--send`, если включено в YAML-конфиге (`email.html: true`).
|
||||||
- Понятные сообщения об ошибках Redmine API, SMTP и файловой системы.
|
- Понятные сообщения об ошибках Redmine API, SMTP и файловой системы.
|
||||||
- Загрузка альтернативного `.env` через `--config`.
|
- Загрузка альтернативного `.env` через `--config`.
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ report:
|
|||||||
no_time: false
|
no_time: false
|
||||||
|
|
||||||
email:
|
email:
|
||||||
|
html: false
|
||||||
smtp:
|
smtp:
|
||||||
host: smtp.example.com
|
host: smtp.example.com
|
||||||
port: 587
|
port: 587
|
||||||
@@ -213,6 +215,13 @@ redmine-reporter --date 2026-06-01--2026-06-30 --output ~/report.xlsx --send
|
|||||||
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
|
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Если в секции `email` установить `html: true`, письмо будет отправлено в двух версиях: plain-text и HTML (таблица отчёта прямо в теле письма). Файл отчёта всё равно прикрепляется, если `attach: true`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
email:
|
||||||
|
html: true
|
||||||
|
```
|
||||||
|
|
||||||
Если секция `email` не настроена — ошибка с пояснением. При ошибке SMTP файл отчёта остаётся на диске, данные не теряются. Поддерживаются `to`, `cc`, `bcc`, TLS, отключение вложения (`attach: false`).
|
Если секция `email` не настроена — ошибка с пояснением. При ошибке SMTP файл отчёта остаётся на диске, данные не теряются. Поддерживаются `to`, `cc`, `bcc`, TLS, отключение вложения (`attach: false`).
|
||||||
|
|
||||||
### Фиксация периода (`--commit`)
|
### Фиксация периода (`--commit`)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ report:
|
|||||||
no_time: false
|
no_time: false
|
||||||
|
|
||||||
email:
|
email:
|
||||||
|
html: false
|
||||||
smtp:
|
smtp:
|
||||||
host: smtp.example.com
|
host: smtp.example.com
|
||||||
port: 587
|
port: 587
|
||||||
@@ -127,6 +128,7 @@ redmine-reporter --commit
|
|||||||
| `subject` | строка | `"Отчёт {author} за {period}"` | Тема письма |
|
| `subject` | строка | `"Отчёт {author} за {period}"` | Тема письма |
|
||||||
| `body_text` | строка | `"Во вложении отчёт."` | Текст письма (plain text) |
|
| `body_text` | строка | `"Во вложении отчёт."` | Текст письма (plain text) |
|
||||||
| `attach` | bool | `true` | Прикреплять файл отчёта. Если `false` — только текст |
|
| `attach` | bool | `true` | Прикреплять файл отчёта. Если `false` — только текст |
|
||||||
|
| `html` | bool | `false` | Добавить HTML-версию тела письма (`multipart/alternative`) |
|
||||||
|
|
||||||
**Подстановки в `subject` и `body_text`:**
|
**Подстановки в `subject` и `body_text`:**
|
||||||
|
|
||||||
@@ -152,6 +154,7 @@ redmine-reporter --commit
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
email:
|
email:
|
||||||
|
html: false
|
||||||
smtp:
|
smtp:
|
||||||
host: smtp.example.com
|
host: smtp.example.com
|
||||||
port: 587
|
port: 587
|
||||||
@@ -181,7 +184,9 @@ email:
|
|||||||
2. Сохраняет отчёт в файл:
|
2. Сохраняет отчёт в файл:
|
||||||
- Если указан `--output` — по явному пути.
|
- Если указан `--output` — по явному пути.
|
||||||
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
|
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
|
||||||
3. Формирует MIME-письмо: тема, текст, вложение (с корректным MIME-типом).
|
3. Формирует MIME-письмо:
|
||||||
|
- Тема, plain-text тело и вложение (если `attach: true`).
|
||||||
|
- При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта.
|
||||||
4. Отправляет через SMTP с TLS (таймаут 30 секунд).
|
4. Отправляет через SMTP с TLS (таймаут 30 секунд).
|
||||||
|
|
||||||
**Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся
|
**Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся
|
||||||
@@ -216,6 +221,7 @@ redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
|
|||||||
| `--send` | Сохранить по шаблону → отправить |
|
| `--send` | Сохранить по шаблону → отправить |
|
||||||
| `--send --output X` | Сохранить в X → отправить |
|
| `--send --output X` | Сохранить в X → отправить |
|
||||||
| `--send --commit` | Сохранить → отправить → зафиксировать период |
|
| `--send --commit` | Сохранить → отправить → зафиксировать период |
|
||||||
|
| `--send` с `email.html: true` | Письмо с plain-text + HTML-таблицей |
|
||||||
| `--send` без `email` в конфиге | Ошибка, exit 1 |
|
| `--send` без `email` в конфиге | Ошибка, exit 1 |
|
||||||
| `--send` при ошибке SMTP | Файл сохранён, ошибка в stderr, exit 1 |
|
| `--send` при ошибке SMTP | Файл сохранён, ошибка в stderr, exit 1 |
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "redmine-reporter"
|
name = "redmine-reporter"
|
||||||
version = "1.9.0"
|
version = "1.10.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" }]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.9.0"
|
__version__ = "1.10.0"
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ def _run_init_config(config_path: str, force: bool) -> int:
|
|||||||
"no_time": False,
|
"no_time": False,
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
|
"html": False,
|
||||||
"smtp": {
|
"smtp": {
|
||||||
"host": "",
|
"host": "",
|
||||||
"port": 587,
|
"port": 587,
|
||||||
@@ -209,7 +210,13 @@ def _save_and_maybe_send(
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_report(email_config, output_arg, author, f"{from_date}--{to_date}")
|
send_report(
|
||||||
|
email_config,
|
||||||
|
output_arg,
|
||||||
|
author,
|
||||||
|
f"{from_date}--{to_date}",
|
||||||
|
rows=rows,
|
||||||
|
)
|
||||||
print(f"📧 Report sent to {', '.join(email_config.to)}")
|
print(f"📧 Report sent to {', '.join(email_config.to)}")
|
||||||
except RedmineAPIError as e:
|
except RedmineAPIError as e:
|
||||||
print(f"❌ {e.message}", file=sys.stderr)
|
print(f"❌ {e.message}", file=sys.stderr)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class EmailConfig:
|
|||||||
subject: str = "Отчёт {author} за {period}"
|
subject: str = "Отчёт {author} за {period}"
|
||||||
body_text: str = "Во вложении отчёт."
|
body_text: str = "Во вложении отчёт."
|
||||||
attach: bool = True
|
attach: bool = True
|
||||||
|
html: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -193,6 +194,7 @@ class AppConfig:
|
|||||||
cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт."
|
cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт."
|
||||||
),
|
),
|
||||||
attach=cls._safe_bool(email_raw.get("attach"), True),
|
attach=cls._safe_bool(email_raw.get("attach"), True),
|
||||||
|
html=cls._safe_bool(email_raw.get("html"), False),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import smtplib
|
|||||||
from email.mime.application import MIMEApplication
|
from email.mime.application import MIMEApplication
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from typing import Dict
|
from typing import Dict, List
|
||||||
|
|
||||||
from .client import RedmineAPIError
|
from .client import RedmineAPIError
|
||||||
from .config import EmailConfig
|
from .config import EmailConfig
|
||||||
|
from .types import ReportRow
|
||||||
|
|
||||||
# Use 8bit transfer encoding for UTF-8 so non-ASCII text (e.g. Russian)
|
# Use 8bit transfer encoding for UTF-8 so non-ASCII text (e.g. Russian)
|
||||||
# appears literally in MIME output instead of base64.
|
# appears literally in MIME output instead of base64.
|
||||||
@@ -33,13 +34,22 @@ def _resolve_mime_type(file_path: str) -> str:
|
|||||||
return MIME_TYPES.get(ext, "application/octet-stream")
|
return MIME_TYPES.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_html_body(rows: List[ReportRow]) -> str:
|
||||||
|
"""Генерирует HTML-версию тела письма через HTMLFormatter."""
|
||||||
|
from .formatters.html import HTMLFormatter
|
||||||
|
|
||||||
|
formatter = HTMLFormatter()
|
||||||
|
return formatter.format(rows)
|
||||||
|
|
||||||
|
|
||||||
def build_message(
|
def build_message(
|
||||||
email_config: EmailConfig,
|
email_config: EmailConfig,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
author: str,
|
author: str,
|
||||||
period: str,
|
period: str,
|
||||||
|
rows: List[ReportRow],
|
||||||
) -> MIMEMultipart:
|
) -> MIMEMultipart:
|
||||||
"""Формирует MIME-письмо с подстановками и вложением."""
|
"""Формирует MIME-письмо с подстановками, телами и вложением."""
|
||||||
subject = email_config.subject.replace("{author}", author).replace(
|
subject = email_config.subject.replace("{author}", author).replace(
|
||||||
"{period}", period
|
"{period}", period
|
||||||
)
|
)
|
||||||
@@ -54,7 +64,15 @@ def build_message(
|
|||||||
if email_config.cc:
|
if email_config.cc:
|
||||||
msg["Cc"] = ", ".join(email_config.cc)
|
msg["Cc"] = ", ".join(email_config.cc)
|
||||||
|
|
||||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
# Тела письма: plain-text всегда, HTML по флагу
|
||||||
|
body_container = MIMEMultipart("alternative")
|
||||||
|
body_container.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
|
||||||
|
if email_config.html:
|
||||||
|
html_body = _build_html_body(rows)
|
||||||
|
body_container.attach(MIMEText(html_body, "html", "utf-8"))
|
||||||
|
|
||||||
|
msg.attach(body_container)
|
||||||
|
|
||||||
if email_config.attach:
|
if email_config.attach:
|
||||||
try:
|
try:
|
||||||
@@ -81,6 +99,7 @@ def send_report(
|
|||||||
file_path: str,
|
file_path: str,
|
||||||
author: str,
|
author: str,
|
||||||
period: str,
|
period: str,
|
||||||
|
rows: List[ReportRow],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Отправляет сгенерированный отчёт по email через SMTP.
|
"""Отправляет сгенерированный отчёт по email через SMTP.
|
||||||
|
|
||||||
@@ -89,12 +108,13 @@ def send_report(
|
|||||||
file_path: Путь к файлу отчёта для вложения.
|
file_path: Путь к файлу отчёта для вложения.
|
||||||
author: Имя автора (для подстановки в тему/тело).
|
author: Имя автора (для подстановки в тему/тело).
|
||||||
period: Строка периода (для подстановки в тему/тело).
|
period: Строка периода (для подстановки в тему/тело).
|
||||||
|
rows: Строки отчёта (для HTML-версии тела письма).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RedmineAPIError: При любой ошибке соединения или отправки.
|
RedmineAPIError: При любой ошибке соединения или отправки.
|
||||||
"""
|
"""
|
||||||
smtp_cfg = email_config.smtp
|
smtp_cfg = email_config.smtp
|
||||||
msg = build_message(email_config, file_path, author, period)
|
msg = build_message(email_config, file_path, author, period, rows)
|
||||||
all_recipients = (
|
all_recipients = (
|
||||||
list(email_config.to) + list(email_config.cc) + list(email_config.bcc)
|
list(email_config.to) + list(email_config.cc) + list(email_config.bcc)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -387,6 +387,21 @@ class TestInitConfig:
|
|||||||
|
|
||||||
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
|
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
def test_init_config_includes_email_html(self, tmp_path):
|
||||||
|
"""--init-config генерирует email.html: 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 "html" in data["email"]
|
||||||
|
assert data["email"]["html"] is False
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, {}, clear=True)
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
def test_init_config_no_env_vars(self, tmp_path):
|
def test_init_config_no_env_vars(self, tmp_path):
|
||||||
"""Без переменных окружения --init-config создаёт скелет."""
|
"""Без переменных окружения --init-config создаёт скелет."""
|
||||||
@@ -1011,6 +1026,98 @@ class TestSendFlag:
|
|||||||
assert code == 0
|
assert code == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendHtmlBody:
|
||||||
|
"""Tests for email.html body generation."""
|
||||||
|
|
||||||
|
@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_passes_rows_to_send_report(
|
||||||
|
self, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""--send передаёт rows в send_report для генерации HTML."""
|
||||||
|
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"
|
||||||
|
" html: true\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
|
||||||
|
|
||||||
|
_, kwargs = mock_send.call_args
|
||||||
|
assert "rows" in kwargs
|
||||||
|
assert len(kwargs["rows"]) == 1
|
||||||
|
|
||||||
|
@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_html_false_does_not_require_html_part(
|
||||||
|
self, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""При email.html: false письмо отправляется без HTML-части."""
|
||||||
|
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()
|
||||||
|
_, kwargs = mock_send.call_args
|
||||||
|
assert "rows" in kwargs
|
||||||
|
|
||||||
|
|
||||||
class TestResolveNoTime:
|
class TestResolveNoTime:
|
||||||
"""Tests for _resolve_no_time()."""
|
"""Tests for _resolve_no_time()."""
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ from unittest import mock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY, AppConfig, Config
|
from redmine_reporter.config import (
|
||||||
|
DEFAULT_REDMINE_VERIFY,
|
||||||
|
AppConfig,
|
||||||
|
Config,
|
||||||
|
EmailConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(
|
@mock.patch.dict(
|
||||||
@@ -613,6 +618,54 @@ class TestGetEmailConfig:
|
|||||||
assert Config.get_email_config() is None
|
assert Config.get_email_config() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailConfigHtml:
|
||||||
|
"""Tests for EmailConfig.html field."""
|
||||||
|
|
||||||
|
def test_email_config_html_from_yaml(self):
|
||||||
|
"""email.html: true загружается из YAML."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"email:\n html: true\n smtp:\n host: smtp.example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert cfg.email.html is True
|
||||||
|
|
||||||
|
def test_email_config_html_defaults_to_false(self):
|
||||||
|
"""email.html по умолчанию False."""
|
||||||
|
assert EmailConfig().html is False
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("email:\n smtp:\n host: smtp.example.com\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert cfg.email.html is False
|
||||||
|
|
||||||
|
def test_email_config_html_false_from_yaml(self):
|
||||||
|
"""email.html: false явно загружается из YAML."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"email:\n html: false\n smtp:\n host: smtp.example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert cfg.email.html is False
|
||||||
|
|
||||||
|
def test_email_config_html_invalid_string_defaults_to_false(self):
|
||||||
|
"""email.html со строкой 'invalid' приводится к False."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"email:\n html: 'invalid'\n smtp:\n host: smtp.example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert cfg.email.html is False
|
||||||
|
|
||||||
|
|
||||||
class TestReportNoTime:
|
class TestReportNoTime:
|
||||||
"""Tests for Config.get_report_no_time()."""
|
"""Tests for Config.get_report_no_time()."""
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class TestBuildMessage:
|
|||||||
"""{author} и {period} подставляются в тему."""
|
"""{author} и {period} подставляются в тему."""
|
||||||
cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False)
|
cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False)
|
||||||
msg = build_message(
|
msg = build_message(
|
||||||
cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30"
|
cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30", []
|
||||||
)
|
)
|
||||||
assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30"
|
assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30"
|
||||||
|
|
||||||
@@ -46,43 +46,44 @@ class TestBuildMessage:
|
|||||||
cfg = _make_email_config(
|
cfg = _make_email_config(
|
||||||
body_text="Автор: {author}, период: {period}", attach=False
|
body_text="Автор: {author}, период: {period}", attach=False
|
||||||
)
|
)
|
||||||
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1")
|
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", [])
|
||||||
plain_part = msg.get_payload()[0]
|
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||||
assert "Автор: Иванов, период: Q1" in plain_part.as_string()
|
assert len(plain_parts) == 1
|
||||||
|
assert "Автор: Иванов, период: Q1" in plain_parts[0].as_string()
|
||||||
|
|
||||||
def test_from_header(self):
|
def test_from_header(self):
|
||||||
cfg = _make_email_config(from_="sender@example.com", attach=False)
|
cfg = _make_email_config(from_="sender@example.com", attach=False)
|
||||||
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P")
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||||
assert msg["From"] == "sender@example.com"
|
assert msg["From"] == "sender@example.com"
|
||||||
|
|
||||||
def test_to_header(self):
|
def test_to_header(self):
|
||||||
cfg = _make_email_config(to=["a@x.com", "b@x.com"], attach=False)
|
cfg = _make_email_config(to=["a@x.com", "b@x.com"], attach=False)
|
||||||
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P")
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||||
assert msg["To"] == "a@x.com, b@x.com"
|
assert msg["To"] == "a@x.com, b@x.com"
|
||||||
|
|
||||||
def test_cc_header(self):
|
def test_cc_header(self):
|
||||||
cfg = _make_email_config(cc=["cc@x.com"], attach=False)
|
cfg = _make_email_config(cc=["cc@x.com"], attach=False)
|
||||||
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P")
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||||
assert msg["Cc"] == "cc@x.com"
|
assert msg["Cc"] == "cc@x.com"
|
||||||
|
|
||||||
def test_bcc_not_in_headers(self):
|
def test_bcc_not_in_headers(self):
|
||||||
"""BCC не должен появляться в заголовках письма."""
|
"""BCC не должен появляться в заголовках письма."""
|
||||||
cfg = _make_email_config(bcc=["hidden@x.com"], attach=False)
|
cfg = _make_email_config(bcc=["hidden@x.com"], attach=False)
|
||||||
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P")
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||||
assert "Bcc" not in msg
|
assert "Bcc" not in msg
|
||||||
|
|
||||||
def test_attachment_present_when_attach_true(self, tmp_path):
|
def test_attachment_present_when_attach_true(self, tmp_path):
|
||||||
report = tmp_path / "report.xlsx"
|
report = tmp_path / "report.xlsx"
|
||||||
report.write_text("fake xlsx content")
|
report.write_text("fake xlsx content")
|
||||||
cfg = _make_email_config(attach=True)
|
cfg = _make_email_config(attach=True)
|
||||||
msg = build_message(cfg, str(report), "A", "P")
|
msg = build_message(cfg, str(report), "A", "P", [])
|
||||||
assert len(msg.get_payload()) == 2
|
assert len(msg.get_payload()) == 2
|
||||||
|
|
||||||
def test_no_attachment_when_attach_false(self, tmp_path):
|
def test_no_attachment_when_attach_false(self, tmp_path):
|
||||||
report = tmp_path / "report.xlsx"
|
report = tmp_path / "report.xlsx"
|
||||||
report.write_text("fake xlsx content")
|
report.write_text("fake xlsx content")
|
||||||
cfg = _make_email_config(attach=False)
|
cfg = _make_email_config(attach=False)
|
||||||
msg = build_message(cfg, str(report), "A", "P")
|
msg = build_message(cfg, str(report), "A", "P", [])
|
||||||
assert len(msg.get_payload()) == 1
|
assert len(msg.get_payload()) == 1
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -103,7 +104,7 @@ class TestBuildMessage:
|
|||||||
report = tmp_path / f"report{ext}"
|
report = tmp_path / f"report{ext}"
|
||||||
report.write_text("content")
|
report.write_text("content")
|
||||||
cfg = _make_email_config()
|
cfg = _make_email_config()
|
||||||
msg = build_message(cfg, str(report), "A", "P")
|
msg = build_message(cfg, str(report), "A", "P", [])
|
||||||
attachment_part = msg.get_payload()[1]
|
attachment_part = msg.get_payload()[1]
|
||||||
assert attachment_part.get_content_type() == expected_mime
|
assert attachment_part.get_content_type() == expected_mime
|
||||||
|
|
||||||
@@ -112,7 +113,97 @@ class TestBuildMessage:
|
|||||||
cfg = _make_email_config()
|
cfg = _make_email_config()
|
||||||
missing = str(tmp_path / "nonexistent.xlsx")
|
missing = str(tmp_path / "nonexistent.xlsx")
|
||||||
with pytest.raises(RedmineAPIError, match="Не удалось прочитать файл"):
|
with pytest.raises(RedmineAPIError, match="Не удалось прочитать файл"):
|
||||||
build_message(cfg, missing, "A", "P")
|
build_message(cfg, missing, "A", "P", [])
|
||||||
|
|
||||||
|
def test_html_part_present_when_html_true(self, tmp_path):
|
||||||
|
"""При email.html: true письмо содержит multipart/alternative с HTML."""
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(html=True)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"project": "Project",
|
||||||
|
"version": "1.0",
|
||||||
|
"issue_id": 1,
|
||||||
|
"subject": "Task",
|
||||||
|
"status_ru": "В работе",
|
||||||
|
"time_text": "2ч",
|
||||||
|
"hours": 2.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||||
|
|
||||||
|
alternatives = [
|
||||||
|
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
|
||||||
|
]
|
||||||
|
assert len(alternatives) == 1
|
||||||
|
|
||||||
|
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
|
||||||
|
assert len(html_parts) == 1
|
||||||
|
|
||||||
|
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||||
|
assert len(plain_parts) == 1
|
||||||
|
|
||||||
|
def test_no_html_part_when_html_false(self, tmp_path):
|
||||||
|
"""При email.html: false письмо содержит только plain-text."""
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(html=False)
|
||||||
|
msg = build_message(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
|
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
|
||||||
|
assert len(html_parts) == 0
|
||||||
|
|
||||||
|
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||||
|
assert len(plain_parts) == 1
|
||||||
|
|
||||||
|
def test_html_part_contains_report_data(self, tmp_path):
|
||||||
|
"""HTML-часть содержит данные отчёта."""
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(html=True)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"project": "Project X",
|
||||||
|
"version": "2.0",
|
||||||
|
"issue_id": 42,
|
||||||
|
"subject": "Important task",
|
||||||
|
"status_ru": "В работе",
|
||||||
|
"time_text": "3ч 30м",
|
||||||
|
"hours": 3.5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||||
|
|
||||||
|
html_part = [p for p in msg.walk() if p.get_content_type() == "text/html"][0]
|
||||||
|
payload = html_part.get_payload(decode=True).decode("utf-8")
|
||||||
|
assert "Project X" in payload
|
||||||
|
assert "Important task" in payload
|
||||||
|
|
||||||
|
def test_plain_text_before_html_in_alternative(self, tmp_path):
|
||||||
|
"""В multipart/alternative plain-text идёт перед HTML."""
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(html=True)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"project": "P",
|
||||||
|
"version": "V",
|
||||||
|
"issue_id": 1,
|
||||||
|
"subject": "S",
|
||||||
|
"status_ru": "В работе",
|
||||||
|
"time_text": "1ч",
|
||||||
|
"hours": 1.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||||
|
|
||||||
|
alternative = [
|
||||||
|
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
|
||||||
|
][0]
|
||||||
|
payloads = alternative.get_payload()
|
||||||
|
assert payloads[0].get_content_type() == "text/plain"
|
||||||
|
assert payloads[1].get_content_type() == "text/html"
|
||||||
|
|
||||||
|
|
||||||
class TestSendReport:
|
class TestSendReport:
|
||||||
@@ -127,7 +218,7 @@ class TestSendReport:
|
|||||||
mock_smtp = mock.MagicMock()
|
mock_smtp = mock.MagicMock()
|
||||||
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
|
||||||
send_report(cfg, str(report), "Кокос А.Н.", "2026-06-01--2026-06-30")
|
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_class.assert_called_once_with("smtp.example.com", 587, timeout=30)
|
||||||
mock_smtp.starttls.assert_called_once()
|
mock_smtp.starttls.assert_called_once()
|
||||||
@@ -143,7 +234,7 @@ class TestSendReport:
|
|||||||
mock_smtp = mock.MagicMock()
|
mock_smtp = mock.MagicMock()
|
||||||
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
|
||||||
send_report(cfg, str(report), "A", "P")
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
mock_smtp.starttls.assert_not_called()
|
mock_smtp.starttls.assert_not_called()
|
||||||
|
|
||||||
@@ -154,7 +245,7 @@ class TestSendReport:
|
|||||||
|
|
||||||
with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")):
|
with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")):
|
||||||
with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"):
|
with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"):
|
||||||
send_report(cfg, str(report), "A", "P")
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
def test_send_auth_error(self, tmp_path):
|
def test_send_auth_error(self, tmp_path):
|
||||||
report = tmp_path / "report.xlsx"
|
report = tmp_path / "report.xlsx"
|
||||||
@@ -169,7 +260,7 @@ class TestSendReport:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"):
|
with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"):
|
||||||
send_report(cfg, str(report), "A", "P")
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
def test_send_timeout_error(self, tmp_path):
|
def test_send_timeout_error(self, tmp_path):
|
||||||
report = tmp_path / "report.xlsx"
|
report = tmp_path / "report.xlsx"
|
||||||
@@ -178,7 +269,7 @@ class TestSendReport:
|
|||||||
|
|
||||||
with mock.patch("smtplib.SMTP", side_effect=TimeoutError()):
|
with mock.patch("smtplib.SMTP", side_effect=TimeoutError()):
|
||||||
with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"):
|
with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"):
|
||||||
send_report(cfg, str(report), "A", "P")
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
def test_send_generic_smtp_error(self, tmp_path):
|
def test_send_generic_smtp_error(self, tmp_path):
|
||||||
report = tmp_path / "report.xlsx"
|
report = tmp_path / "report.xlsx"
|
||||||
@@ -193,7 +284,7 @@ class TestSendReport:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"):
|
with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"):
|
||||||
send_report(cfg, str(report), "A", "P")
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
def test_send_includes_cc_and_bcc(self, tmp_path):
|
def test_send_includes_cc_and_bcc(self, tmp_path):
|
||||||
report = tmp_path / "report.xlsx"
|
report = tmp_path / "report.xlsx"
|
||||||
@@ -204,7 +295,7 @@ class TestSendReport:
|
|||||||
mock_smtp = mock.MagicMock()
|
mock_smtp = mock.MagicMock()
|
||||||
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
|
||||||
send_report(cfg, str(report), "A", "P")
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
call_args = mock_smtp.send_message.call_args
|
call_args = mock_smtp.send_message.call_args
|
||||||
msg = call_args.args[0]
|
msg = call_args.args[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user