11 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
5f51d40fef Merge branch 'fix/default-to-today' 2026-07-10 16:52:55 +07:00
Кокос Артем Николаевич
22f1733a5d refactor: use single date.today() call in get_default_date_range
Avoids potential midnight inconsistency between start and end of the
fallback period. Related to #50.
2026-07-10 16:50:44 +07:00
Кокос Артем Николаевич
17b0e99aa3 chore: bump version to 1.10.1 and update README
- Update README to document optional DEFAULT_TO_DATE / default_to
- Default end date falls back to today when not explicitly set

Closes #50
2026-07-10 16:46:30 +07:00
Кокос Артем Николаевич
8992bb922e docs: explain optional period.default_to and today fallback 2026-07-10 16:40:22 +07:00
Кокос Артем Николаевич
608afe08e3 fix: default missing period.default_to to today 2026-07-10 16:33:28 +07:00
Кокос Артем Николаевич
e1862462af 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
2026-07-10 15:24:27 +07:00
Кокос Артем Николаевич
863ad50cc3 feat: add report.no_time config option for automatic modes
Add YAML section `report.no_time` that controls `--no-time` behavior
in automatic modes (`--commit`, `--send`). CLI flag `--no-time`
always wins. Manual `--output` ignores the YAML setting.

- Config.get_report_no_time() reads the YAML value (defaults to false)
- cli._resolve_no_time() encapsulates CLI > YAML priority
- --init-config now generates the report section
- docs updated in README.md and docs/CONFIG.md

Closes #49
2026-07-10 14:38:07 +07:00
Кокос Артем Николаевич
5e1c366a60 docs: update README, CONFIG and pyproject.toml for --send feature
- README: add --send flag to features, usage examples, full flag list;
  replace black/isort with ruff in dev section; add email config
  template variables docs
- CONFIG: new email section with all fields documented, --send usage
  examples, error handling and flag compatibility table
- pyproject.toml: remove unused black and isort from dev dependencies
  and their tool configs (project uses ruff for both lint and format)
2026-07-10 12:46:46 +07:00
Кокос Артем Николаевич
0968560090 style: apply ruff format to all source files 2026-07-10 12:39:24 +07:00
Кокос Артем Николаевич
25425901b1 fix: handle save_period_to_config errors, remove duplicate _MockIssue
- cli.py: wrap save_period_to_config in try/except to avoid
  unhandled traceback on disk full / permission denial (I2)
- test_cli.py: remove duplicate _MockIssue class definition (I3)
2026-07-10 12:37:46 +07:00
Кокос Артем Николаевич
b0e353c565 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
2026-07-10 12:37:02 +07:00
19 changed files with 1869 additions and 189 deletions

1
.gitignore vendored
View File

@@ -85,6 +85,7 @@ secrets.json
# Temporary files # Temporary files
*.tmp *.tmp
*.bak *.bak
docs/superpowers
# Just in case # Just in case
.~* .~*

104
README.md
View File

@@ -17,10 +17,12 @@ CLI-инструмент для генерации отчётов по зада
- Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx). - Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
- Excel-отчёт с merge-ячейками по проекту/версии, итогами, автошириной, автофильтром и закреплённой шапкой. - Excel-отчёт с merge-ячейками по проекту/версии, итогами, автошириной, автофильтром и закреплённой шапкой.
- Сводка по времени (`--summary`). - Сводка по времени (`--summary`).
- YAML-конфиг (`~/.config/redmine-reporter/config.yml`): шаблон имени файла, путь по умолчанию, период, SMTP. - YAML-конфиг (`~/.config/redmine-reporter/config.yml`): шаблон имени файла, путь по умолчанию, период, email, настройки содержимого отчёта (`report.no_time`).
- Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание. - Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание.
- `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска. - `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска.
- Понятные сообщения об ошибках Redmine API (401/403/5xx, таймаут, сеть). - `--send`: отправка отчёта по email через SMTP сразу после генерации.
- HTML-версия тела письма при `--send`, если включено в YAML-конфиге (`email.html: true`).
- Понятные сообщения об ошибках Redmine API, SMTP и файловой системы.
- Загрузка альтернативного `.env` через `--config`. - Загрузка альтернативного `.env` через `--config`.
## Установка ## Установка
@@ -70,6 +72,7 @@ redmine:
period: period:
precision: date # date | datetime precision: date # date | datetime
default_from: "2026-06-01" default_from: "2026-06-01"
# default_to можно не указывать — конец периода будет сегодня
default_to: "2026-06-30" default_to: "2026-06-30"
dynamic: false dynamic: false
# last_used заполняется --commit (см. docs/CONFIG.md) # last_used заполняется --commit (см. docs/CONFIG.md)
@@ -79,7 +82,11 @@ output:
filename: "{author}_{from}_{to}.{ext}" filename: "{author}_{from}_{to}.{ext}"
default_format: xlsx default_format: xlsx
report:
no_time: false
email: email:
html: false
smtp: smtp:
host: smtp.example.com host: smtp.example.com
port: 587 port: 587
@@ -89,11 +96,17 @@ email:
from: bot@example.com from: bot@example.com
to: to:
- boss@example.com - boss@example.com
cc: []
bcc: []
subject: "Отчёт {author} за {period}" subject: "Отчёт {author} за {period}"
body_text: "Во вложении отчёт."
attach: true
``` ```
Шаблон `output.filename` поддерживает `{author}`, `{from}`, `{to}`, `{date}` (DD_MM_YYYY), `{ext}`. Шаблон `output.filename` поддерживает `{author}`, `{from}`, `{to}`, `{date}` (DD_MM_YYYY), `{ext}`.
Шаблоны `email.subject` и `email.body_text` поддерживают `{author}`, `{period}` (строка диапазона, например `2026-06-01--2026-06-30`).
Подробнее: [docs/CONFIG.md](docs/CONFIG.md). Подробнее: [docs/CONFIG.md](docs/CONFIG.md).
### `.env` (legacy) ### `.env` (legacy)
@@ -103,6 +116,7 @@ REDMINE_URL=https://red.eltex.loc/
REDMINE_API_KEY=ваш_api_token REDMINE_API_KEY=ваш_api_token
REDMINE_AUTHOR=Иванов Иван Иванович REDMINE_AUTHOR=Иванов Иван Иванович
DEFAULT_FROM_DATE=2026-01-01 DEFAULT_FROM_DATE=2026-01-01
# DEFAULT_TO_DATE можно не задавать — тогда конец периода будет сегодня
DEFAULT_TO_DATE=2026-01-31 DEFAULT_TO_DATE=2026-01-31
``` ```
@@ -116,7 +130,7 @@ DEFAULT_TO_DATE=2026-01-31
| `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. | | `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. |
| `REDMINE_AUTHOR` | Нет | Имя автора для отчёта. | | `REDMINE_AUTHOR` | Нет | Имя автора для отчёта. |
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). | | `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). |
| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию (`YYYY-MM-DD`). | | `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию (`YYYY-MM-DD`). Если не задана, а `DEFAULT_FROM_DATE` задана — используется сегодняшняя дата. |
| `REDMINE_VERIFY` | Нет | TLS-проверка: `true` / `false` / путь к CA bundle. | | `REDMINE_VERIFY` | Нет | TLS-проверка: `true` / `false` / путь к CA bundle. |
## Использование ## Использование
@@ -125,6 +139,8 @@ DEFAULT_TO_DATE=2026-01-31
source .venv/bin/activate source .venv/bin/activate
``` ```
### Основные сценарии
Отчёт за период по умолчанию: Отчёт за период по умолчанию:
```bash ```bash
@@ -164,41 +180,53 @@ redmine-reporter --compact
redmine-reporter --debug redmine-reporter --debug
``` ```
Экспорт с явным путём: ### Экспорт в файл
Явный путь:
```bash ```bash
redmine-reporter --output report.xlsx redmine-reporter --output report.xlsx
redmine-reporter --output /path/to/report.odt redmine-reporter --output /path/to/report.odt
``` ```
Экспорт — только формат (путь и имя берутся из YAML-шаблона): Только формат (путь и имя берутся из YAML-шаблона):
```bash ```bash
redmine-reporter --output xlsx # → output.dir/отчёт_01_07_2026.xlsx redmine-reporter --output xlsx # → output.dir/отчёт_01_07_2026.xlsx
redmine-reporter --output odt # → output.dir/отчёт_01_07_2026.odt redmine-reporter --output odt # → output.dir/отчёт_01_07_2026.odt
``` ```
Экспорт — путь без расширения (дописывается `default_format` из конфига): Путь без расширения (дописывается `default_format` из конфига):
```bash ```bash
redmine-reporter --output /tmp/report # → /tmp/report.xlsx (если default_format: xlsx) redmine-reporter --output /tmp/report # → /tmp/report.xlsx (если default_format: xlsx)
``` ```
Без времени / с разбивкой по активностям: ### Отправка по email (`--send`)
Отправить отчёт на email, указанный в YAML-конфиге (секция `email`):
```bash ```bash
redmine-reporter --no-time # Сохранить по шаблону и отправить
redmine-reporter --by-activity redmine-reporter --date 2026-06-01--2026-06-30 --send
redmine-reporter --by-activity --summary
# С явным путём
redmine-reporter --date 2026-06-01--2026-06-30 --output ~/report.xlsx --send
# Вместе с фиксацией периода
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
``` ```
Сводка: Если в секции `email` установить `html: true`, письмо будет отправлено в двух версиях: plain-text и HTML (таблица отчёта прямо в теле письма). Файл отчёта всё равно прикрепляется, если `attach: true`.
```bash ```yaml
redmine-reporter --summary email:
html: true
``` ```
Фиксация периода (`--commit`): Если секция `email` не настроена — ошибка с пояснением. При ошибке SMTP файл отчёта остаётся на диске, данные не теряются. Поддерживаются `to`, `cc`, `bcc`, TLS, отключение вложения (`attach: false`).
### Фиксация периода (`--commit`)
```bash ```bash
# Сгенерировать, сохранить в файл по шаблону, запомнить период # Сгенерировать, сохранить в файл по шаблону, запомнить период
@@ -215,6 +243,24 @@ redmine-reporter
redmine-reporter --commit redmine-reporter --commit
``` ```
### Сводка и опции
Без времени / с разбивкой по активностям:
```bash
redmine-reporter --no-time
redmine-reporter --by-activity
redmine-reporter --by-activity --summary
```
`--no-time` можно задать в YAML-конфиге (`report.no_time: true`), чтобы автоматические режимы (`--commit`, `--send`) не включали затраченное время без явного флага. При ручном `--output` YAML-значение не применяется — только CLI-флаг `--no-time`.
Сводка:
```bash
redmine-reporter --summary
```
## Форматы вывода ## Форматы вывода
| Формат | Особенности | | Формат | Особенности |
@@ -226,6 +272,33 @@ redmine-reporter --commit
| **JSON** | Массив объектов: `project`, `version`, `issue_id`, `subject`, `status`, `time`. | | **JSON** | Массив объектов: `project`, `version`, `issue_id`, `subject`, `status`, `time`. |
| **Excel (.xlsx)** | Merge cells, колонки `Hours`/`Spent Time`, итоги, автоширина, автофильтр, freeze panes. | | **Excel (.xlsx)** | Merge cells, колонки `Hours`/`Spent Time`, итоги, автоширина, автофильтр, freeze panes. |
## Полный список флагов
```
--date DATE Диапазон дат: YYYY-MM-DD--YYYY-MM-DD
--compact Компактный текстовый вывод вместо таблицы
--output PATH/FMT Путь к файлу (.odt/.csv/.md/.html/.json/.xlsx)
или bare-формат (xlsx/odt/...) — путь из конфига
--author NAME Переопределить имя автора
--no-time Не включать затраченное время в таблицу
--url URL Переопределить Redmine URL
--api-key KEY Переопределить Redmine API key
--config PATH Путь к альтернативному .env-файлу
--verbose Подробный вывод
--debug Отладочный вывод
--version Показать версию и выйти
--summary Вывести сводку по времени в stderr
--user-id ID Redmine ID пользователя для отчёта
--user-login LOGIN Логин пользователя Redmine
--user-name NAME Полное имя пользователя Redmine
--by-activity Разбить время по типам активности
--init-config Сгенерировать YAML-конфиг и выйти
--force Перезаписать существующий конфиг (с --init-config)
--config-path PATH Путь к YAML-конфигу (по умолчанию ~/.config/redmine-reporter/config.yml)
--commit Сохранить отчёт в файл и зафиксировать период в конфиге
--send Отправить отчёт по email после сохранения
```
## Разработка ## Разработка
Проверки перед коммитом: Проверки перед коммитом:
@@ -233,8 +306,7 @@ redmine-reporter --commit
```bash ```bash
pytest pytest
ruff check redmine_reporter tests ruff check redmine_reporter tests
black --check redmine_reporter tests ruff format --check redmine_reporter tests
isort --check-only redmine_reporter tests
mypy redmine_reporter mypy redmine_reporter
``` ```

View File

@@ -32,6 +32,7 @@ redmine:
period: period:
precision: date precision: date
default_from: "2026-06-01" default_from: "2026-06-01"
# default_to можно не указывать — тогда конец периода будет сегодня
default_to: "2026-06-30" default_to: "2026-06-30"
dynamic: false dynamic: false
last_used: last_used:
@@ -43,7 +44,11 @@ output:
filename: "{author}_{from}_{to}.{ext}" filename: "{author}_{from}_{to}.{ext}"
default_format: xlsx default_format: xlsx
report:
no_time: false
email: email:
html: false
smtp: smtp:
host: smtp.example.com host: smtp.example.com
port: 587 port: 587
@@ -69,6 +74,23 @@ email:
`last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется. `last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется.
### `period.default_to` — необязательное окончание периода
Если `period.default_to` не задан, а `period.default_from` задан, инструмент
использует сегодняшнюю дату в качестве конца периода.
```yaml
period:
default_from: "2026-07-01"
# default_to отсутствует → конец периода = сегодня
```
Это предотвращает устаревание периода, когда отчёт генерируется автоматически
(`--send`, `--commit`) без явного `--date`.
Аналогично работает `DEFAULT_TO_DATE`: если переменная не задана, а
`DEFAULT_FROM_DATE` задана, конец периода = сегодня.
### `--commit` — автофиксация периода ### `--commit` — автофиксация периода
Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода. Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода.
@@ -103,6 +125,124 @@ redmine-reporter --date 2026-06-15--2026-06-20 --commit
redmine-reporter --commit redmine-reporter --commit
``` ```
### `email` — настройка отправки по почте
Секция `email` используется флагом `--send`. Если секция не настроена или `smtp.host`
пуст, `--send` завершится с ошибкой «Email не настроен».
**Все поля:**
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| `smtp.host` | строка | `""` | Адрес SMTP-сервера |
| `smtp.port` | число | `587` | Порт SMTP |
| `smtp.user` | строка | `""` | Логин для аутентификации |
| `smtp.password` | строка | `""` | Пароль (рекомендуется `${SMTP_PASSWORD}`) |
| `smtp.tls` | bool | `true` | Использовать STARTTLS |
| `from` | строка | `""` | Адрес отправителя |
| `to` | список | `[]` | Основные получатели |
| `cc` | список | `[]` | Копия |
| `bcc` | список | `[]` | Скрытая копия (не отображается в заголовках письма) |
| `subject` | строка | `"Отчёт {author} за {period}"` | Тема письма |
| `body_text` | строка | `"Во вложении отчёт."` | Текст письма (plain text) |
| `attach` | bool | `true` | Прикреплять файл отчёта. Если `false` — только текст |
| `html` | bool | `false` | Добавить HTML-версию тела письма (`multipart/alternative`) |
**Подстановки в `subject` и `body_text`:**
| Плейсхолдер | Описание | Пример |
|---|---|---|
| `{author}` | Имя автора из конфига или `--author` | `Кокос А.А.` |
| `{period}` | Строка диапазона дат | `2026-06-01--2026-06-30` |
**MIME-тип вложения** определяется по расширению файла:
| Расширение | 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` |
Неизвестное расширение → `application/octet-stream`.
**Пример конфигурации:**
```yaml
email:
html: false
smtp:
host: smtp.example.com
port: 587
user: bot@example.com
password: ${SMTP_PASSWORD}
tls: true
from: bot@example.com
to:
- boss@example.com
- team-lead@example.com
cc:
- manager@example.com
bcc: []
subject: "Отчёт {author} за {period}"
body_text: "Во вложении отчёт за период {period}."
attach: true
```
### `--send` — отправка отчёта по email
Флаг `--send` отправляет сгенерированный отчёт через SMTP сразу после сохранения
в файл. Требует настроенную секцию `email` в YAML-конфиге.
**Что делает:**
1. Генерирует отчёт как обычно.
2. Сохраняет отчёт в файл:
- Если указан `--output` — по явному пути.
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
3. Формирует MIME-письмо:
- Тема, plain-text тело и вложение (если `attach: true`).
- При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта.
4. Отправляет через SMTP с TLS (таймаут 30 секунд).
**Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся
на диске, данные не теряются.
**Ошибки SMTP:**
- Нет соединения → `"Не удалось подключиться к SMTP-серверу host:port"`
- Неверный логин/пароль → `"Ошибка аутентификации SMTP. Проверьте логин и пароль."`
- Таймаут → `"Таймаут соединения с SMTP-сервером."`
- Другая ошибка → `"Ошибка отправки письма: <детали>"`
Все ошибки выводятся в stderr, код возврата 1.
**Примеры:**
```bash
# Отправить отчёт за июнь (сохранится по шаблону output.filename)
redmine-reporter --date 2026-06-01--2026-06-30 --send
# С явным путём
redmine-reporter --date 2026-06-01--2026-06-30 --output ~/report.xlsx --send
# Отправить и зафиксировать период
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
```
**Совместимость с другими флагами:**
| Комбинация | Поведение |
|---|---|
| `--send` | Сохранить по шаблону → отправить |
| `--send --output X` | Сохранить в X → отправить |
| `--send --commit` | Сохранить → отправить → зафиксировать период |
| `--send` с `email.html: true` | Письмо с plain-text + HTML-таблицей |
| `--send` без `email` в конфиге | Ошибка, exit 1 |
| `--send` при ошибке SMTP | Файл сохранён, ошибка в stderr, exit 1 |
### `output` — путь и имя файла по умолчанию ### `output` — путь и имя файла по умолчанию
Секция управляет тем, куда и с каким именем сохраняется отчёт, когда `--output` не содержит полного пути. Секция управляет тем, куда и с каким именем сохраняется отчёт, когда `--output` не содержит полного пути.
@@ -160,6 +300,36 @@ email:
**не запрещены** — если вписать `api_key: "abc123"` напрямую, система примет. **не запрещены** — если вписать `api_key: "abc123"` напрямую, система примет.
Права `0600` — основная защита. Права `0600` — основная защита.
### `report` — настройки содержимого отчёта
Секция управляет тем, что попадает в сгенерированный отчёт.
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта |
`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`).
При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`.
CLI-флаг `--no-time` всегда имеет приоритет над YAML.
**Примеры:**
```yaml
report:
no_time: true
```
```bash
# Автоматический режим: время не выводится
redmine-reporter --commit
# Ручной режим: report.no_time игнорируется, время выводится
redmine-reporter --output report.odt
# Ручной режим с явным флагом: время не выводится
redmine-reporter --output report.odt --no-time
```
## Разрешение выходного пути ## Разрешение выходного пути
Функция `resolve_output_path()` определяет итоговый путь к файлу: Функция `resolve_output_path()` определяет итоговый путь к файлу:
@@ -201,6 +371,10 @@ vim ~/.config/redmine-reporter/config.yml
| `--init-config --force` | Перезаписать существующий YAML | | `--init-config --force` | Перезаписать существующий YAML |
| `--config-path PATH` | Сохранить YAML по указанному пути (по умолчанию `~/.config/redmine-reporter/config.yml`) | | `--config-path PATH` | Сохранить YAML по указанному пути (по умолчанию `~/.config/redmine-reporter/config.yml`) |
Если `DEFAULT_TO_DATE` не задана, а `DEFAULT_FROM_DATE` задана, сгенерированный
YAML будет содержать пустое `default_to`, и при запуске инструмент использует
сегодняшнюю дату.
### Проверка после миграции ### Проверка после миграции
```bash ```bash

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "redmine-reporter" name = "redmine-reporter"
version = "1.9.0" version = "1.10.1"
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" }]
@@ -30,8 +30,6 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
"black>=23.0",
"isort>=5.12",
"mypy>=1.0", "mypy>=1.0",
"ruff>=0.1.0", "ruff>=0.1.0",
] ]
@@ -46,14 +44,6 @@ include = ["redmine_reporter*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
"redmine_reporter" = ["templates/template.odt"] "redmine_reporter" = ["templates/template.odt"]
[tool.black]
line-length = 100
target-version = ['py39']
[tool.isort]
profile = "black"
multi_line_output = 3
[tool.mypy] [tool.mypy]
warn_unused_configs = true warn_unused_configs = true

View File

@@ -1 +1 @@
__version__ = "1.9.0" __version__ = "1.10.1"

View File

@@ -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
@@ -24,7 +25,9 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
from_date, to_date = parts[0].strip(), parts[1].strip() from_date, to_date = parts[0].strip(), parts[1].strip()
date_pattern = r"\d{4}-\d{2}-\d{2}" date_pattern = r"\d{4}-\d{2}-\d{2}"
if not re.fullmatch(date_pattern, from_date) or not re.fullmatch(date_pattern, to_date): if not re.fullmatch(date_pattern, from_date) or not re.fullmatch(
date_pattern, to_date
):
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try: try:
@@ -45,7 +48,7 @@ def _run_init_config(config_path: str, force: bool) -> int:
if path.exists() and not force: if path.exists() and not force:
print( print(
f"⚠️ {path} already exists.\n" f" Use --init-config --force to overwrite.", f"⚠️ {path} already exists.\n Use --init-config --force to overwrite.",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1
@@ -69,7 +72,11 @@ def _run_init_config(config_path: str, force: bool) -> int:
"filename": "{author}_{from}_{to}.{ext}", "filename": "{author}_{from}_{to}.{ext}",
"default_format": "xlsx", "default_format": "xlsx",
}, },
"report": {
"no_time": False,
},
"email": { "email": {
"html": False,
"smtp": { "smtp": {
"host": "", "host": "",
"port": 587, "port": 587,
@@ -89,7 +96,9 @@ def _run_init_config(config_path: str, force: bool) -> int:
ensure_config_dir(path.parent) ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh: with open(path, "w", encoding="utf-8") as fh:
yaml.dump(data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False) yaml.dump(
data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
)
path.chmod(0o600) path.chmod(0o600)
sections_found = [s for s in data if data[s]] sections_found = [s for s in data if data[s]]
@@ -122,6 +131,100 @@ def _compute_dedup_cutoff() -> Optional[datetime]:
return None return None
def _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool:
"""Возвращает финальное значение no_time.
Приоритет:
1. CLI-флаг --no-time (всегда побеждает).
2. YAML report.no_time (только в автоматических режимах --commit/--send).
3. Иначе False.
"""
if cli_flag:
return True
if is_auto_mode:
return Config.get_report_no_time()
return False
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}",
rows=rows,
)
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",
@@ -148,7 +251,9 @@ def main(argv: Optional[List[str]] = None) -> int:
"--no-time", action="store_true", help="Do not include spent time into table" "--no-time", action="store_true", help="Do not include spent time into table"
) )
parser.add_argument("--url", help="Override Redmine URL from .env (REDMINE_URL)") parser.add_argument("--url", help="Override Redmine URL from .env (REDMINE_URL)")
parser.add_argument("--api-key", help="Override Redmine API key from .env (REDMINE_API_KEY)") parser.add_argument(
"--api-key", help="Override Redmine API key from .env (REDMINE_API_KEY)"
)
parser.add_argument("--config", help="Path to .env config file") parser.add_argument("--config", help="Path to .env config file")
parser.add_argument("--verbose", action="store_true", help="Enable verbose output") parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
parser.add_argument("--debug", action="store_true", help="Enable debug output") parser.add_argument("--debug", action="store_true", help="Enable debug output")
@@ -200,6 +305,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 +325,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(
@@ -292,9 +403,12 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"✅ Total issues: {len(issue_hours)} [{date_arg}]", file=sys.stderr) print(f"✅ Total issues: {len(issue_hours)} [{date_arg}]", file=sys.stderr)
is_auto_mode = args.commit or args.send
no_time = _resolve_no_time(args.no_time, is_auto_mode)
rows = build_grouped_report( rows = build_grouped_report(
issue_hours, issue_hours,
fill_time=not args.no_time, fill_time=not no_time,
by_activity=args.by_activity, by_activity=args.by_activity,
) )
@@ -312,65 +426,50 @@ 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: if args.output or args.commit:
output_arg = resolve_output_path( if args.output:
args.output, output_arg = resolve_output_path(
output_dir=Config.get_output_dir(), args.output,
filename_template=Config.get_output_filename(), output_dir=Config.get_output_dir(),
default_format=Config.get_output_default_format(), filename_template=Config.get_output_filename(),
author=Config.get_author(args.author), default_format=Config.get_output_default_format(),
from_date=from_date, author=Config.get_author(args.author),
to_date=to_date, from_date=from_date,
) to_date=to_date,
)
else:
# --commit без --output: используем default_format
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 output_arg is None: if output_arg is None:
print( print(
"Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx", "Не удалось определить путь для сохранения отчёта.", file=sys.stderr
file=sys.stderr,
) )
return 1 return 1
output_ext = os.path.splitext(output_arg)[1].lower() ret = _save_and_maybe_send(
rows,
if not output_ext: output_arg,
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), 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=no_time,
do_send=args.send,
) )
if ret != 0:
return ret
if not formatter: elif args.send:
if output_ext == ".odt": # --send без --output и --commit: сохраняем по шаблону и отправляем
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
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,
@@ -383,28 +482,22 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
if output_arg is None: if output_arg is None:
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=no_time,
do_send=True,
) )
if ret != 0:
if not formatter: return ret
print(f"Не удалось создать форматтер для {output_ext}", file=sys.stderr)
return 1
try:
formatter.save(rows, output_arg)
print(f"✅ Report saved to {output_arg}")
except Exception as e:
print(f"❌ Export error: {e}", file=sys.stderr)
return 1
else: else:
if args.compact: if args.compact:
@@ -437,7 +530,16 @@ def main(argv: Optional[List[str]] = None) -> int:
from_str = from_date from_str = from_date
to_str = to_date to_str = to_date
save_period_to_config(args.config_path, from_str, to_str, precision, dynamic) try:
save_period_to_config(
args.config_path, from_str, to_str, precision, dynamic
)
except Exception as e:
print(
f"Не удалось сохранить период в конфиг: {e}",
file=sys.stderr,
)
return 1
print( print(
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}", f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
file=sys.stderr, file=sys.stderr,

View File

@@ -170,7 +170,9 @@ def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE): for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE] chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
issue_list_str = ",".join(str(x) for x in chunk) issue_list_str = ",".join(str(x) for x in chunk)
issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc") issues = redmine.issue.filter(
issue_id=issue_list_str, status_id="*", sort="project:asc"
)
all_issues.extend(issues) all_issues.extend(issues)
return all_issues return all_issues
@@ -262,7 +264,9 @@ def fetch_issues_with_spent_time(
) )
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {} activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
time_entries = list( time_entries = list(
redmine.time_entry.filter(user_id=target_user_id, from_date=from_date, to_date=to_date) redmine.time_entry.filter(
user_id=target_user_id, from_date=from_date, to_date=to_date
)
) )
except RedmineAPIError: except RedmineAPIError:
raise raise

View File

@@ -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
@@ -61,6 +62,7 @@ class AppConfig:
output_dir: str = "" output_dir: str = ""
output_filename: str = "{author}_{from}_{to}.{ext}" output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx" output_default_format: str = "xlsx"
report_no_time: bool = False
email: EmailConfig = field(default_factory=EmailConfig) email: EmailConfig = field(default_factory=EmailConfig)
@classmethod @classmethod
@@ -86,6 +88,7 @@ class AppConfig:
"redmine", "redmine",
"period", "period",
"output", "output",
"report",
"email", "email",
} }
for key in raw: for key in raw:
@@ -106,7 +109,9 @@ class AppConfig:
output_dir=cls._resolve_str(raw, "output", "dir"), output_dir=cls._resolve_str(raw, "output", "dir"),
output_filename=cls._resolve_str(raw, "output", "filename") output_filename=cls._resolve_str(raw, "output", "filename")
or "{author}_{from}_{to}.{ext}", or "{author}_{from}_{to}.{ext}",
output_default_format=cls._resolve_str(raw, "output", "default_format") or "xlsx", output_default_format=cls._resolve_str(raw, "output", "default_format")
or "xlsx",
report_no_time=cls._resolve_bool(raw, "report", "no_time"),
email=cls._resolve_email(raw), email=cls._resolve_email(raw),
) )
@@ -189,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
@@ -343,12 +349,32 @@ 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_report_no_time(cls) -> bool:
"""Возвращает report.no_time из YAML-конфига (по умолчанию False)."""
if cls._app:
return cls._app.report_no_time
return False
@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()
to_env = os.getenv("DEFAULT_TO_DATE", "").strip() to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
if from_env and to_env: today = date.today()
return f"{from_env}--{to_env}" today_str = today.isoformat()
if from_env:
return f"{from_env}--{to_env or today_str}"
if ( if (
cls._app cls._app
@@ -363,17 +389,12 @@ class Config:
) )
return f"{nf}--{nt}" return f"{nf}--{nt}"
if cls._app and cls._app.period_default_from and cls._app.period_default_to: if cls._app and cls._app.period_default_from:
return f"{cls._app.period_default_from}--{cls._app.period_default_to}" default_to = cls._app.period_default_to or today_str
return f"{cls._app.period_default_from}--{default_to}"
today = date.today()
start = today.replace(day=1) start = today.replace(day=1)
if today.month == 12: return f"{start.isoformat()}--{today_str}"
next_month = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month = today.replace(month=today.month + 1, day=1)
end = next_month - timedelta(days=1)
return f"{start.isoformat()}--{end.isoformat()}"
@classmethod @classmethod
def validate(cls) -> None: def validate(cls) -> None:
@@ -387,7 +408,9 @@ class Config:
) )
def compute_next_period(last_from: str, last_to: str, precision: str) -> tuple[str, str]: def compute_next_period(
last_from: str, last_to: str, precision: str
) -> tuple[str, str]:
"""Compute the next report period based on the last committed period. """Compute the next report period based on the last committed period.
- For a full calendar month → next full calendar month. - For a full calendar month → next full calendar month.

View File

@@ -15,7 +15,9 @@ from .base import Formatter
class ODTFormatter(Formatter): class ODTFormatter(Formatter):
"""Форматтер для экспорта в ODT.""" """Форматтер для экспорта в ODT."""
def __init__(self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs): def __init__(
self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs
):
""" """
Инициализирует форматтер с параметрами для шапки отчета. Инициализирует форматтер с параметрами для шапки отчета.
""" """
@@ -28,7 +30,11 @@ class ODTFormatter(Formatter):
Форматирует данные в объект OpenDocument. Форматирует данные в объект OpenDocument.
""" """
with resources.files("redmine_reporter").joinpath("templates/template.odt").open("rb") as f: with (
resources.files("redmine_reporter")
.joinpath("templates/template.odt")
.open("rb") as f
):
doc = load(f) doc = load(f)
# Удаляем все текстовые параграфы из шаблона, оставляя только # Удаляем все текстовые параграфы из шаблона, оставляя только
@@ -52,7 +58,9 @@ class ODTFormatter(Formatter):
# Стиль ячеек # Стиль ячеек
cell_style_name = "TableCellStyle" cell_style_name = "TableCellStyle"
cell_style = Style(name=cell_style_name, family="table-cell") cell_style = Style(name=cell_style_name, family="table-cell")
cell_props = TableCellProperties(padding="0.04in", border="0.05pt solid #000000") cell_props = TableCellProperties(
padding="0.04in", border="0.05pt solid #000000"
)
cell_style.addElement(cell_props) cell_style.addElement(cell_props)
doc.automaticstyles.addElement(cell_style) doc.automaticstyles.addElement(cell_style)
@@ -100,7 +108,9 @@ class ODTFormatter(Formatter):
# в остальных — covered-cell для валидности ODF (#13) # в остальных — covered-cell для валидности ODF (#13)
if first_version_in_project and first_row_in_version: if first_version_in_project and first_row_in_version:
cell_project = TableCell(stylename=cell_style_name) cell_project = TableCell(stylename=cell_style_name)
cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) cell_project.setAttribute(
"numberrowsspanned", str(total_project_rows)
)
p = P(stylename=para_style_name, text=project) p = P(stylename=para_style_name, text=project)
cell_project.addElement(p) cell_project.addElement(p)
row.addElement(cell_project) row.addElement(cell_project)
@@ -111,7 +121,9 @@ class ODTFormatter(Formatter):
# в остальных — covered-cell для валидности ODF (#13) # в остальных — covered-cell для валидности ODF (#13)
if first_row_in_version: if first_row_in_version:
cell_version = TableCell(stylename=cell_style_name) cell_version = TableCell(stylename=cell_style_name)
cell_version.setAttribute("numberrowsspanned", str(row_span_version)) cell_version.setAttribute(
"numberrowsspanned", str(row_span_version)
)
p = P(stylename=para_style_name, text=version) p = P(stylename=para_style_name, text=version)
cell_version.addElement(p) cell_version.addElement(p)
row.addElement(cell_version) row.addElement(cell_version)

View File

@@ -19,8 +19,12 @@ class XLSXFormatter(Formatter):
и числовой столбец с часами для удобного суммирования. и числовой столбец с часами для удобного суммирования.
""" """
_HEADER_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") _HEADER_FILL = PatternFill(
_TOTAL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") start_color="D9E1F2", end_color="D9E1F2", fill_type="solid"
)
_TOTAL_FILL = PatternFill(
start_color="FFF2CC", end_color="FFF2CC", fill_type="solid"
)
_BORDER = Border( _BORDER = Border(
left=Side(style="thin"), left=Side(style="thin"),
right=Side(style="thin"), right=Side(style="thin"),
@@ -40,7 +44,15 @@ class XLSXFormatter(Formatter):
else: else:
ws.title = "Report" ws.title = "Report"
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Hours", "Spent Time"] headers = [
"Project",
"Version",
"Issue ID",
"Subject",
"Status",
"Hours",
"Spent Time",
]
ws.append(headers) ws.append(headers)
self._style_header_row(ws, headers) self._style_header_row(ws, headers)
@@ -89,7 +101,10 @@ class XLSXFormatter(Formatter):
) )
self._style_total_row(ws, current_row, bold=False) self._style_total_row(ws, current_row, bold=False)
ws.merge_cells( ws.merge_cells(
start_row=current_row, start_column=2, end_row=current_row, end_column=5 start_row=current_row,
start_column=2,
end_row=current_row,
end_column=5,
) )
current_row += 1 current_row += 1
@@ -99,7 +114,8 @@ class XLSXFormatter(Formatter):
if not self.no_time: if not self.no_time:
project_hours = sum( project_hours = sum(
sum(r.get("hours", 0.0) for r in task_rows) for task_rows in versions.values() sum(r.get("hours", 0.0) for r in task_rows)
for task_rows in versions.values()
) )
ws.append( ws.append(
[ [
@@ -114,7 +130,10 @@ class XLSXFormatter(Formatter):
) )
self._style_total_row(ws, current_row, bold=True) self._style_total_row(ws, current_row, bold=True)
ws.merge_cells( ws.merge_cells(
start_row=current_row, start_column=1, end_row=current_row, end_column=5 start_row=current_row,
start_column=1,
end_row=current_row,
end_column=5,
) )
current_row += 1 current_row += 1
project_totals[project] = project_hours project_totals[project] = project_hours
@@ -137,7 +156,9 @@ class XLSXFormatter(Formatter):
] ]
) )
self._style_total_row(ws, current_row, bold=True) self._style_total_row(ws, current_row, bold=True)
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=5) ws.merge_cells(
start_row=current_row, start_column=1, end_row=current_row, end_column=5
)
for start, end in project_ranges: for start, end in project_ranges:
ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1) ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1)
@@ -167,7 +188,9 @@ class XLSXFormatter(Formatter):
cell.font = Font(bold=True) cell.font = Font(bold=True)
cell.fill = self._HEADER_FILL cell.fill = self._HEADER_FILL
cell.border = self._BORDER cell.border = self._BORDER
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) cell.alignment = Alignment(
horizontal="center", vertical="center", wrap_text=True
)
def _style_data_row(self, ws: Worksheet, row: int) -> None: def _style_data_row(self, ws: Worksheet, row: int) -> None:
for col_idx in range(1, 8): for col_idx in range(1, 8):
@@ -190,7 +213,15 @@ class XLSXFormatter(Formatter):
def _apply_column_widths(self, ws: Worksheet) -> None: def _apply_column_widths(self, ws: Worksheet) -> None:
# Минимальные ширины по умолчанию # Минимальные ширины по умолчанию
widths: Dict[int, float] = {1: 18.0, 2: 16.0, 3: 12.0, 4: 45.0, 5: 14.0, 6: 10.0, 7: 14.0} widths: Dict[int, float] = {
1: 18.0,
2: 16.0,
3: 12.0,
4: 45.0,
5: 14.0,
6: 10.0,
7: 14.0,
}
for row in ws.iter_rows(min_row=2, max_row=ws.max_row): for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
for col_idx, cell in enumerate(row, start=1): for col_idx, cell in enumerate(row, start=1):

143
redmine_reporter/mailer.py Normal file
View File

@@ -0,0 +1,143 @@
"""Отправка сгенерированного отчёта по 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, List
from .client import RedmineAPIError
from .config import EmailConfig
from .types import ReportRow
# 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_html_body(rows: List[ReportRow]) -> str:
"""Генерирует HTML-версию тела письма через HTMLFormatter."""
from .formatters.html import HTMLFormatter
formatter = HTMLFormatter()
return formatter.format(rows)
def build_message(
email_config: EmailConfig,
file_path: str,
author: str,
period: str,
rows: List[ReportRow],
) -> 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)
# Тела письма: 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:
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,
rows: List[ReportRow],
) -> None:
"""Отправляет сгенерированный отчёт по email через SMTP.
Args:
email_config: Настройки SMTP и письма.
file_path: Путь к файлу отчёта для вложения.
author: Имя автора (для подстановки в тему/тело).
period: Строка периода (для подстановки в тему/тело).
rows: Строки отчёта (для HTML-версии тела письма).
Raises:
RedmineAPIError: При любой ошибке соединения или отправки.
"""
smtp_cfg = email_config.smtp
msg = build_message(email_config, file_path, author, period, rows)
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

View File

@@ -45,7 +45,9 @@ def build_grouped_report(
""" """
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе # Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)) issue_hours = sorted(
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
)
rows: List[ReportRow] = [] rows: List[ReportRow] = []
prev_project: str = "" prev_project: str = ""
@@ -67,7 +69,9 @@ def build_grouped_report(
time_text = "" time_text = ""
display_project = project if project != prev_project else "" display_project = project if project != prev_project else ""
display_version = version if (project != prev_project or version != prev_version) else "" display_version = (
version if (project != prev_project or version != prev_version) else ""
)
rows.append( rows.append(
cast( cast(
@@ -122,7 +126,9 @@ def calculate_summary(
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()}, **{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
} }
if by_activity: if by_activity:
result.update({f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()}) result.update(
{f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()}
)
return result return result

View File

@@ -21,7 +21,9 @@ def resolve_env_vars(value: str) -> str:
var_name = match.group(1) var_name = match.group(1)
env_value = os.environ.get(var_name) env_value = os.environ.get(var_name)
if env_value is None: if env_value is None:
logger.warning("Environment variable %s is not set, using empty string", var_name) logger.warning(
"Environment variable %s is not set, using empty string", var_name
)
return "" return ""
return env_value return env_value
@@ -182,5 +184,7 @@ def save_period_to_config(
ensure_config_dir(path.parent) ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh: with open(path, "w", encoding="utf-8") as fh:
yaml.dump(raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False) yaml.dump(
raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
)
path.chmod(0o600) path.chmod(0o600)

View File

@@ -62,7 +62,9 @@ def test_cli_returns_zero_on_no_entries(mock_fetch):
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_cli_config_error(): def test_cli_config_error():
"""Невалидный конфиг -- выход 1.""" """Невалидный конфиг -- выход 1."""
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", "/nonexistent/config.yml"]) code = main(
["--date", "2026-01-01--2026-01-31", "--config-path", "/nonexistent/config.yml"]
)
assert code == 1 assert code == 1
@@ -159,17 +161,6 @@ def test_cli_verbose_and_debug_flags_accepted(mock_fetch):
assert code == 0 assert code == 0
class _MockIssue:
"""Простой mock Redmine Issue для CLI-тестов."""
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
self.id = issue_id
self.subject = subject
self.project = project
self.status = status
self.fixed_version = None
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_url_and_api_key_override_env(mock_fetch): def test_cli_url_and_api_key_override_env(mock_fetch):
@@ -194,7 +185,7 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
"""--config загружает переменные из указанного .env-файла.""" """--config загружает переменные из указанного .env-файла."""
config_path = tmp_path / "custom.env" config_path = tmp_path / "custom.env"
config_path.write_text( config_path.write_text(
"REDMINE_URL=https://config.redmine.loc\n" "REDMINE_API_KEY=config-token\n", "REDMINE_URL=https://config.redmine.loc\nREDMINE_API_KEY=config-token\n",
encoding="utf-8", encoding="utf-8",
) )
mock_fetch.return_value = None mock_fetch.return_value = None
@@ -259,7 +250,9 @@ def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при таймауте.""" """CLI выводит понятное сообщение при таймауте."""
from redmine_reporter.client import RedmineAPIError from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Redmine request timed out after 30 seconds") mock_fetch.side_effect = RedmineAPIError(
"Redmine request timed out after 30 seconds"
)
code = main(["--date", "2026-01-01--2026-01-31"]) code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr() captured = capsys.readouterr()
assert code == 1 assert code == 1
@@ -274,7 +267,9 @@ def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xlsx") output = str(tmp_path / "report.xlsx")
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get_formatter: with mock.patch(
"redmine_reporter.cli.get_formatter_by_extension"
) as mock_get_formatter:
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
mock_get_formatter.return_value = mock_formatter mock_get_formatter.return_value = mock_formatter
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"]) main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
@@ -392,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 создаёт скелет."""
@@ -408,6 +418,21 @@ class TestInitConfig:
assert data["redmine"]["url"] == "" assert data["redmine"]["url"] == ""
assert data["redmine"]["api_key"] == "" assert data["redmine"]["api_key"] == ""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_includes_report_section(self, tmp_path):
"""--init-config генерирует секцию report с no_time: 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 "report" in data
assert data["report"]["no_time"] is False
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@@ -465,7 +490,9 @@ class TestOutputPathResolution:
@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")
def test_output_without_extension_appends_default_format(self, mock_fetch, tmp_path): def test_output_without_extension_appends_default_format(
self, mock_fetch, tmp_path
):
"""--output report без расширения → добавляет .xlsx.""" """--output report без расширения → добавляет .xlsx."""
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
@@ -559,7 +586,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}})) config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
@@ -594,7 +623,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "datetime", "dynamic": True}})) config_path.write_text(
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
@@ -641,7 +672,9 @@ class TestCommitFlag:
@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.save_period_to_config") @mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_dynamic_false_overwrites_defaults(self, mock_save, mock_fetch, tmp_path): def test_commit_dynamic_false_overwrites_defaults(
self, mock_save, mock_fetch, tmp_path
):
"""При dynamic=false --commit перезаписывает default_from/to.""" """При dynamic=false --commit перезаписывает default_from/to."""
import yaml import yaml
@@ -649,7 +682,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": False}})) config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": False}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
@@ -674,7 +709,9 @@ class TestCommitFlag:
@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.save_period_to_config") @mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_without_output_uses_default_path(self, mock_save, mock_fetch, tmp_path): def test_commit_without_output_uses_default_path(
self, mock_save, mock_fetch, tmp_path
):
"""--commit без --output сохраняет файл по шаблону из конфига.""" """--commit без --output сохраняет файл по шаблону из конфига."""
import yaml import yaml
@@ -713,7 +750,9 @@ class TestCommitFlag:
@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.save_period_to_config") @mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_prints_info_to_stderr(self, mock_save, mock_fetch, capsys, tmp_path): def test_commit_prints_info_to_stderr(
self, mock_save, mock_fetch, capsys, tmp_path
):
"""--commit выводит сообщение о фиксации в stderr.""" """--commit выводит сообщение о фиксации в stderr."""
import yaml import yaml
@@ -721,7 +760,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}})) config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
@@ -752,7 +793,9 @@ class TestCommitFlag:
mock_fetch.return_value = [] mock_fetch.return_value = []
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}})) config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
code = main( code = main(
[ [
@@ -765,3 +808,560 @@ 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
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:
"""Tests for _resolve_no_time()."""
def test_cli_flag_wins_in_manual_mode(self):
"""CLI --no-time побеждает в ручном режиме."""
from redmine_reporter.cli import _resolve_no_time
assert _resolve_no_time(True, False) is True
def test_cli_flag_wins_in_auto_mode(self):
"""CLI --no-time побеждает в автоматическом режиме."""
from redmine_reporter.cli import _resolve_no_time
assert _resolve_no_time(True, True) is True
@mock.patch("redmine_reporter.cli.Config.get_report_no_time", return_value=True)
def test_yaml_applies_in_auto_mode(self, mock_get):
"""YAML report.no_time применяется в автоматическом режиме."""
from redmine_reporter.cli import _resolve_no_time
assert _resolve_no_time(False, True) is True
mock_get.assert_called_once()
def test_default_in_auto_mode(self):
"""Без YAML report.no_time в автоматическом режиме — False."""
from redmine_reporter.cli import _resolve_no_time
assert _resolve_no_time(False, True) is False
@mock.patch("redmine_reporter.cli.Config.get_report_no_time", return_value=True)
def test_yaml_ignored_in_manual_mode(self, mock_get):
"""YAML report.no_time игнорируется в ручном режиме --output."""
from redmine_reporter.cli import _resolve_no_time
assert _resolve_no_time(False, False) is False
mock_get.assert_not_called()
class TestReportNoTimeIntegration:
"""Интеграционные тесты для report.no_time из YAML."""
@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_report_no_time_true(self, mock_save, mock_fetch, tmp_path):
"""--commit с report.no_time: true передаёт no_time=True в форматтер."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump(
{
"redmine": {"url": "https://x.com", "api_key": "token"},
"report": {"no_time": True},
"period": {"precision": "date", "dynamic": True},
}
)
)
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(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
_, kwargs = mock_get.call_args
assert kwargs.get("no_time") 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_report_no_time_false(self, mock_save, mock_fetch, tmp_path):
"""--commit без report.no_time передаёт no_time=False в форматтер."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump(
{
"redmine": {"url": "https://x.com", "api_key": "token"},
"report": {"no_time": False},
"period": {"precision": "date", "dynamic": True},
}
)
)
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(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
_, kwargs = mock_get.call_args
assert kwargs.get("no_time") is False
@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_cli_no_time_overrides_yaml(self, mock_save, mock_fetch, tmp_path):
"""--commit с --no-time побеждает report.no_time: false."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump(
{
"redmine": {"url": "https://x.com", "api_key": "token"},
"report": {"no_time": False},
"period": {"precision": "date", "dynamic": True},
}
)
)
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(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--no-time",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
_, kwargs = mock_get.call_args
assert kwargs.get("no_time") is True
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_output_ignores_report_no_time_true(self, mock_fetch, tmp_path):
"""--output (ручной режим) игнорирует report.no_time: true."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump(
{
"redmine": {"url": "https://x.com", "api_key": "token"},
"report": {"no_time": True},
}
)
)
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(
[
"--date",
"2026-06-01--2026-06-30",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
_, kwargs = mock_get.call_args
assert kwargs.get("no_time") is False
@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):
"""--send с report.no_time: true передаёт no_time=True в форматтер."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump(
{
"redmine": {"url": "https://x.com", "api_key": "token"},
"report": {"no_time": True},
"email": {
"smtp": {
"host": "smtp.example.com",
"port": 587,
"user": "bot",
"password": "secret",
},
"from": "bot@example.com",
"to": ["boss@example.com"],
},
}
)
)
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(
[
"--date",
"2026-06-01--2026-06-30",
"--send",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
_, kwargs = mock_get.call_args
assert kwargs.get("no_time") is True

View File

@@ -157,7 +157,9 @@ def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
assert "key" not in kwargs assert "key" not in kwargs
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True) @mock.patch.dict(
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_custom_verify_path(mock_redmine_class): def test_fetch_uses_custom_verify_path(mock_redmine_class):
mock_redmine = mock_redmine_class.return_value mock_redmine = mock_redmine_class.return_value
@@ -390,7 +392,9 @@ def test_fetch_mounts_retry_adapter(mock_redmine_class):
assert "http://" in prefixes assert "http://" in prefixes
# Проверяем retry-конфигурацию адаптера # Проверяем retry-конфигурацию адаптера
https_adapter = next(call.args[1] for call in mount_calls if call.args[0] == "https://") https_adapter = next(
call.args[1] for call in mount_calls if call.args[0] == "https://"
)
max_retries = https_adapter.max_retries max_retries = https_adapter.max_retries
assert max_retries.total == 3 assert max_retries.total == 3
assert 429 in max_retries.status_forcelist assert 429 in max_retries.status_forcelist
@@ -422,7 +426,9 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
ids_str = kwargs.get("issue_id", "") ids_str = kwargs.get("issue_id", "")
call_chunks.append(ids_str) call_chunks.append(ids_str)
ids = [int(x) for x in ids_str.split(",")] ids = [int(x) for x in ids_str.split(",")]
return [mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids] return [
mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids
]
mock_redmine.issue.filter.side_effect = issue_filter_side_effect mock_redmine.issue.filter.side_effect = issue_filter_side_effect
@@ -473,7 +479,9 @@ def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
mock_issue2.project = "P" mock_issue2.project = "P"
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2] mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None assert result is not None
assert len(result) == 1 assert len(result) == 1
@@ -482,7 +490,9 @@ def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_dedup_filters_entries_with_old_created_even_if_updated_recently(mock_redmine_class): def test_dedup_filters_entries_with_old_created_even_if_updated_recently(
mock_redmine_class,
):
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика).""" """Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -500,7 +510,9 @@ def test_dedup_filters_entries_with_old_created_even_if_updated_recently(mock_re
mock_redmine.time_entry.filter.return_value = [e1] mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = [] mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is None assert result is None
@@ -531,7 +543,9 @@ def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
mock_issue1.status = "New" mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1] mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None assert result is not None
assert len(result) == 1 assert len(result) == 1
@@ -563,7 +577,9 @@ def test_dedup_entries_without_created_on_are_kept(mock_redmine_class):
mock_issue1.status = "New" mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1] mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None assert result is not None
assert len(result) == 1 assert len(result) == 1
@@ -589,7 +605,9 @@ def test_dedup_handles_string_created_on(mock_redmine_class):
mock_redmine.time_entry.filter.return_value = [e1] mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = [] mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is None assert result is None
@@ -665,7 +683,9 @@ def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
mock_issue_akiy.status = "New" mock_issue_akiy.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy] mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None assert result is not None
assert len(result) == 2 assert len(result) == 2

View File

@@ -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(
@@ -74,21 +79,29 @@ def test_get_default_date_range_from_env():
assert Config.get_default_date_range() == "2026-01-01--2026-01-31" assert Config.get_default_date_range() == "2026-01-01--2026-01-31"
@mock.patch.dict(
os.environ,
{"DEFAULT_FROM_DATE": "2026-01-01"},
clear=True,
)
def test_get_default_date_range_from_env_without_to():
"""Если DEFAULT_TO_DATE не задан, конец периода — сегодня."""
from datetime import date
today = date.today().isoformat()
assert Config.get_default_date_range() == f"2026-01-01--{today}"
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_get_default_date_range_fallback(): def test_get_default_date_range_fallback():
"""Если даты не заданы -- используется текущий месяц.""" """Если даты не заданы -- используется текущий месяц с начала до сегодня."""
from datetime import date, timedelta from datetime import date
today = date.today() today = date.today()
start = today.replace(day=1) start = today.replace(day=1)
if today.month == 12:
next_month = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month = today.replace(month=today.month + 1, day=1)
end = next_month - timedelta(days=1)
result = Config.get_default_date_range() result = Config.get_default_date_range()
assert result == f"{start.isoformat()}--{end.isoformat()}" assert result == f"{start.isoformat()}--{today.isoformat()}"
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
@@ -328,7 +341,9 @@ class TestConfigYamlFallback:
assert Config.get_redmine_url() == "https://cli-override.example.com" assert Config.get_redmine_url() == "https://cli-override.example.com"
@mock.patch.dict(os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True) @mock.patch.dict(
os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True
)
def test_env_beats_yaml(self): def test_env_beats_yaml(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml" yaml_path = Path(tmp) / "config.yml"
@@ -501,7 +516,9 @@ class TestComputeNextPeriod:
def test_datetime_precision_moves_by_seconds(self): def test_datetime_precision_moves_by_seconds(self):
from redmine_reporter.config import compute_next_period from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime") nf, nt = compute_next_period(
"2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime"
)
assert nf == "2026-06-30T12:00:01" assert nf == "2026-06-30T12:00:01"
assert nt == "2026-06-30T15:00:01" assert nt == "2026-06-30T15:00:01"
@@ -557,3 +574,159 @@ 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"
@mock.patch.dict(os.environ, {}, clear=True)
def test_falls_back_to_today_when_default_to_missing(self):
"""Если default_from задан, а default_to нет — конец периода сегодня."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-04-01'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
today = date.today().isoformat()
result = Config.get_default_date_range()
assert result == f"2026-04-01--{today}"
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
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:
"""Tests for Config.get_report_no_time()."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_report_no_time_from_yaml(self):
"""report.no_time: true загружается из YAML."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n no_time: true\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_report_no_time() is True
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_report_no_time_defaults_to_false(self):
"""Без YAML-конфига report.no_time по умолчанию False."""
Config._app = None
assert Config.get_report_no_time() is False
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_report_no_time_from_yaml_false(self):
"""report.no_time: false явно загружается из YAML."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n no_time: false\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_report_no_time() is False
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_report_no_time_invalid_type_defaults_to_false(self):
"""report.no_time со строковым значением приводится к False."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n no_time: 'invalid'\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_report_no_time() is False

View File

@@ -134,7 +134,9 @@ def odt_formatter():
) )
), ),
): ):
yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31") yield ODTFormatter(
author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31"
)
# -- Тесты упаковки formatters как полноценного пакета -- # -- Тесты упаковки formatters как полноценного пакета --
@@ -171,7 +173,11 @@ def _simulate_missing_odfpy():
saved = {} saved = {}
for key in list(sys.modules.keys()): for key in list(sys.modules.keys()):
if key == "odf" or key.startswith("odf.") or key == "redmine_reporter.formatters.odt": if (
key == "odf"
or key.startswith("odf.")
or key == "redmine_reporter.formatters.odt"
):
saved[key] = sys.modules.pop(key) saved[key] = sys.modules.pop(key)
return saved return saved
@@ -474,7 +480,9 @@ def test_odt_empty_author_no_garbage_in_header(fake_rows):
) )
), ),
): ):
formatter = ODTFormatter(author="", from_date="2026-01-01", to_date="2026-01-31") formatter = ODTFormatter(
author="", from_date="2026-01-01", to_date="2026-01-31"
)
doc = formatter.format(fake_rows) doc = formatter.format(fake_rows)
from odf.text import P from odf.text import P
@@ -501,7 +509,9 @@ def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
) )
), ),
): ):
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31") formatter = ODTFormatter(
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
)
output_file = tmp_path / "report.odt" output_file = tmp_path / "report.odt"
formatter.save(fake_rows, str(output_file)) formatter.save(fake_rows, str(output_file))
@@ -537,7 +547,9 @@ def test_odt_has_covered_cells_for_spans(fake_rows):
) )
), ),
): ):
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31") formatter = ODTFormatter(
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
)
doc = formatter.format(fake_rows) doc = formatter.format(fake_rows)
from odf.table import CoveredTableCell from odf.table import CoveredTableCell

303
tests/test_mailer.py Normal file
View File

@@ -0,0 +1,303 @@
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_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
assert len(plain_parts) == 1
assert "Автор: Иванов, период: Q1" in plain_parts[0].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", [])
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": "",
"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": "",
"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:
"""Тесты отправки письма."""
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"

View File

@@ -217,7 +217,9 @@ class TestSavePeriodToConfig:
from redmine_reporter.yaml_config import save_period_to_config from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True) save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
with open(config_path) as fh: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
@@ -233,10 +235,12 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( config_path.write_text(
"period:\n" " last_used:\n" " from: '2026-05-01'\n" " to: '2026-05-31'\n" "period:\n last_used:\n from: '2026-05-01'\n to: '2026-05-31'\n"
) )
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True) save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
with open(config_path) as fh: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
@@ -251,10 +255,12 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( config_path.write_text(
"period:\n" " default_from: '2026-01-01'\n" " default_to: '2026-01-31'\n" "period:\n default_from: '2026-01-01'\n default_to: '2026-01-31'\n"
) )
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", False) save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", False
)
with open(config_path) as fh: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
@@ -299,7 +305,9 @@ class TestSavePeriodToConfig:
" dir: /tmp\n" " dir: /tmp\n"
) )
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True) save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
with open(config_path) as fh: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
@@ -314,7 +322,9 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True) save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
mode = config_path.stat().st_mode & 0o777 mode = config_path.stat().st_mode & 0o777
assert mode == 0o600 assert mode == 0o600