Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1862462af | ||
|
|
863ad50cc3 | ||
|
|
5e1c366a60 | ||
|
|
0968560090 | ||
|
|
25425901b1 | ||
|
|
b0e353c565 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -85,6 +85,7 @@ secrets.json
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
docs/superpowers
|
||||
|
||||
# Just in case
|
||||
.~*
|
||||
|
||||
100
README.md
100
README.md
@@ -17,10 +17,12 @@ CLI-инструмент для генерации отчётов по зада
|
||||
- Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
|
||||
- Excel-отчёт с merge-ячейками по проекту/версии, итогами, автошириной, автофильтром и закреплённой шапкой.
|
||||
- Сводка по времени (`--summary`).
|
||||
- YAML-конфиг (`~/.config/redmine-reporter/config.yml`): шаблон имени файла, путь по умолчанию, период, SMTP.
|
||||
- YAML-конфиг (`~/.config/redmine-reporter/config.yml`): шаблон имени файла, путь по умолчанию, период, email, настройки содержимого отчёта (`report.no_time`).
|
||||
- Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание.
|
||||
- `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска.
|
||||
- Понятные сообщения об ошибках Redmine API (401/403/5xx, таймаут, сеть).
|
||||
- `--send`: отправка отчёта по email через SMTP сразу после генерации.
|
||||
- HTML-версия тела письма при `--send`, если включено в YAML-конфиге (`email.html: true`).
|
||||
- Понятные сообщения об ошибках Redmine API, SMTP и файловой системы.
|
||||
- Загрузка альтернативного `.env` через `--config`.
|
||||
|
||||
## Установка
|
||||
@@ -79,7 +81,11 @@ output:
|
||||
filename: "{author}_{from}_{to}.{ext}"
|
||||
default_format: xlsx
|
||||
|
||||
report:
|
||||
no_time: false
|
||||
|
||||
email:
|
||||
html: false
|
||||
smtp:
|
||||
host: smtp.example.com
|
||||
port: 587
|
||||
@@ -89,11 +95,17 @@ email:
|
||||
from: bot@example.com
|
||||
to:
|
||||
- boss@example.com
|
||||
cc: []
|
||||
bcc: []
|
||||
subject: "Отчёт {author} за {period}"
|
||||
body_text: "Во вложении отчёт."
|
||||
attach: true
|
||||
```
|
||||
|
||||
Шаблон `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).
|
||||
|
||||
### `.env` (legacy)
|
||||
@@ -125,6 +137,8 @@ DEFAULT_TO_DATE=2026-01-31
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### Основные сценарии
|
||||
|
||||
Отчёт за период по умолчанию:
|
||||
|
||||
```bash
|
||||
@@ -164,41 +178,53 @@ redmine-reporter --compact
|
||||
redmine-reporter --debug
|
||||
```
|
||||
|
||||
Экспорт с явным путём:
|
||||
### Экспорт в файл
|
||||
|
||||
Явный путь:
|
||||
|
||||
```bash
|
||||
redmine-reporter --output report.xlsx
|
||||
redmine-reporter --output /path/to/report.odt
|
||||
```
|
||||
|
||||
Экспорт — только формат (путь и имя берутся из YAML-шаблона):
|
||||
Только формат (путь и имя берутся из YAML-шаблона):
|
||||
|
||||
```bash
|
||||
redmine-reporter --output xlsx # → output.dir/отчёт_01_07_2026.xlsx
|
||||
redmine-reporter --output odt # → output.dir/отчёт_01_07_2026.odt
|
||||
```
|
||||
|
||||
Экспорт — путь без расширения (дописывается `default_format` из конфига):
|
||||
Путь без расширения (дописывается `default_format` из конфига):
|
||||
|
||||
```bash
|
||||
redmine-reporter --output /tmp/report # → /tmp/report.xlsx (если default_format: xlsx)
|
||||
```
|
||||
|
||||
Без времени / с разбивкой по активностям:
|
||||
### Отправка по email (`--send`)
|
||||
|
||||
Отправить отчёт на email, указанный в YAML-конфиге (секция `email`):
|
||||
|
||||
```bash
|
||||
redmine-reporter --no-time
|
||||
redmine-reporter --by-activity
|
||||
redmine-reporter --by-activity --summary
|
||||
# Сохранить по шаблону и отправить
|
||||
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
|
||||
```
|
||||
|
||||
Сводка:
|
||||
Если в секции `email` установить `html: true`, письмо будет отправлено в двух версиях: plain-text и HTML (таблица отчёта прямо в теле письма). Файл отчёта всё равно прикрепляется, если `attach: true`.
|
||||
|
||||
```bash
|
||||
redmine-reporter --summary
|
||||
```yaml
|
||||
email:
|
||||
html: true
|
||||
```
|
||||
|
||||
Фиксация периода (`--commit`):
|
||||
Если секция `email` не настроена — ошибка с пояснением. При ошибке SMTP файл отчёта остаётся на диске, данные не теряются. Поддерживаются `to`, `cc`, `bcc`, TLS, отключение вложения (`attach: false`).
|
||||
|
||||
### Фиксация периода (`--commit`)
|
||||
|
||||
```bash
|
||||
# Сгенерировать, сохранить в файл по шаблону, запомнить период
|
||||
@@ -215,6 +241,24 @@ redmine-reporter
|
||||
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 +270,33 @@ redmine-reporter --commit
|
||||
| **JSON** | Массив объектов: `project`, `version`, `issue_id`, `subject`, `status`, `time`. |
|
||||
| **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 +304,7 @@ redmine-reporter --commit
|
||||
```bash
|
||||
pytest
|
||||
ruff check redmine_reporter tests
|
||||
black --check redmine_reporter tests
|
||||
isort --check-only redmine_reporter tests
|
||||
ruff format --check redmine_reporter tests
|
||||
mypy redmine_reporter
|
||||
```
|
||||
|
||||
|
||||
152
docs/CONFIG.md
152
docs/CONFIG.md
@@ -43,7 +43,11 @@ output:
|
||||
filename: "{author}_{from}_{to}.{ext}"
|
||||
default_format: xlsx
|
||||
|
||||
report:
|
||||
no_time: false
|
||||
|
||||
email:
|
||||
html: false
|
||||
smtp:
|
||||
host: smtp.example.com
|
||||
port: 587
|
||||
@@ -103,6 +107,124 @@ redmine-reporter --date 2026-06-15--2026-06-20 --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` не содержит полного пути.
|
||||
@@ -160,6 +282,36 @@ email:
|
||||
**не запрещены** — если вписать `api_key: "abc123"` напрямую, система примет.
|
||||
Права `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()` определяет итоговый путь к файлу:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "redmine-reporter"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
description = "Redmine time-entry based issue reporter for internal use"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
||||
@@ -30,8 +30,6 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"black>=23.0",
|
||||
"isort>=5.12",
|
||||
"mypy>=1.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
@@ -46,14 +44,6 @@ include = ["redmine_reporter*"]
|
||||
[tool.setuptools.package-data]
|
||||
"redmine_reporter" = ["templates/template.odt"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py39']
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
multi_line_output = 3
|
||||
|
||||
[tool.mypy]
|
||||
warn_unused_configs = true
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.9.0"
|
||||
__version__ = "1.10.0"
|
||||
|
||||
@@ -13,6 +13,7 @@ from . import __version__
|
||||
from .client import RedmineAPIError, fetch_issues_with_spent_time
|
||||
from .config import Config
|
||||
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 .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()
|
||||
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")
|
||||
|
||||
try:
|
||||
@@ -45,7 +48,7 @@ def _run_init_config(config_path: str, force: bool) -> int:
|
||||
|
||||
if path.exists() and not force:
|
||||
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,
|
||||
)
|
||||
return 1
|
||||
@@ -69,7 +72,11 @@ def _run_init_config(config_path: str, force: bool) -> int:
|
||||
"filename": "{author}_{from}_{to}.{ext}",
|
||||
"default_format": "xlsx",
|
||||
},
|
||||
"report": {
|
||||
"no_time": False,
|
||||
},
|
||||
"email": {
|
||||
"html": False,
|
||||
"smtp": {
|
||||
"host": "",
|
||||
"port": 587,
|
||||
@@ -89,7 +96,9 @@ def _run_init_config(config_path: str, force: bool) -> int:
|
||||
|
||||
ensure_config_dir(path.parent)
|
||||
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)
|
||||
|
||||
sections_found = [s for s in data if data[s]]
|
||||
@@ -122,6 +131,100 @@ def _compute_dedup_cutoff() -> Optional[datetime]:
|
||||
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:
|
||||
parser = argparse.ArgumentParser(
|
||||
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"
|
||||
)
|
||||
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("--verbose", action="store_true", help="Enable verbose 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",
|
||||
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)
|
||||
|
||||
# --init-config: обработка до всего остального
|
||||
@@ -215,6 +325,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
args.user_name,
|
||||
args.no_time,
|
||||
args.by_activity,
|
||||
args.send,
|
||||
]
|
||||
if any(report_flags):
|
||||
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)
|
||||
|
||||
is_auto_mode = args.commit or args.send
|
||||
no_time = _resolve_no_time(args.no_time, is_auto_mode)
|
||||
|
||||
rows = build_grouped_report(
|
||||
issue_hours,
|
||||
fill_time=not args.no_time,
|
||||
fill_time=not no_time,
|
||||
by_activity=args.by_activity,
|
||||
)
|
||||
|
||||
@@ -312,6 +426,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
activity = key.split(":", 1)[1]
|
||||
print(f" [{activity}]: {value}h", file=sys.stderr)
|
||||
|
||||
if args.output or args.commit:
|
||||
if args.output:
|
||||
output_arg = resolve_output_path(
|
||||
args.output,
|
||||
@@ -322,55 +437,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
|
||||
if output_arg is None:
|
||||
print(
|
||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
output_ext = os.path.splitext(output_arg)[1].lower()
|
||||
|
||||
if not output_ext:
|
||||
print(
|
||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
formatter = get_formatter_by_extension(
|
||||
output_ext,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
no_time=args.no_time,
|
||||
)
|
||||
|
||||
if not formatter:
|
||||
if output_ext == ".odt":
|
||||
print(
|
||||
"❌ odfpy is not installed. Install with: pip install odfpy",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
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:
|
||||
# --commit без --output: используем default_format
|
||||
default_format = Config.get_output_default_format()
|
||||
output_arg = resolve_output_path(
|
||||
default_format,
|
||||
@@ -383,28 +451,53 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
)
|
||||
|
||||
if output_arg is None:
|
||||
print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
|
||||
print(
|
||||
"❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
|
||||
output_ext = os.path.splitext(output_arg)[1].lower()
|
||||
formatter = get_formatter_by_extension(
|
||||
output_ext,
|
||||
ret = _save_and_maybe_send(
|
||||
rows,
|
||||
output_arg,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
no_time=no_time,
|
||||
do_send=args.send,
|
||||
)
|
||||
if ret != 0:
|
||||
return ret
|
||||
|
||||
elif args.send:
|
||||
# --send без --output и --commit: сохраняем по шаблону и отправляем
|
||||
default_format = Config.get_output_default_format()
|
||||
output_arg = resolve_output_path(
|
||||
default_format,
|
||||
output_dir=Config.get_output_dir(),
|
||||
filename_template=Config.get_output_filename(),
|
||||
default_format=default_format,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
no_time=args.no_time,
|
||||
)
|
||||
|
||||
if not formatter:
|
||||
print(f"❌ Не удалось создать форматтер для {output_ext}", file=sys.stderr)
|
||||
if output_arg is None:
|
||||
print(
|
||||
"❌ Не удалось определить путь для сохранения отчёта.", 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
|
||||
ret = _save_and_maybe_send(
|
||||
rows,
|
||||
output_arg,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
no_time=no_time,
|
||||
do_send=True,
|
||||
)
|
||||
if ret != 0:
|
||||
return ret
|
||||
|
||||
else:
|
||||
if args.compact:
|
||||
@@ -437,7 +530,16 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
from_str = from_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(
|
||||
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
|
||||
file=sys.stderr,
|
||||
|
||||
@@ -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):
|
||||
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
|
||||
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)
|
||||
return all_issues
|
||||
|
||||
@@ -262,7 +264,9 @@ def fetch_issues_with_spent_time(
|
||||
)
|
||||
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
|
||||
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:
|
||||
raise
|
||||
|
||||
@@ -38,6 +38,7 @@ class EmailConfig:
|
||||
subject: str = "Отчёт {author} за {period}"
|
||||
body_text: str = "Во вложении отчёт."
|
||||
attach: bool = True
|
||||
html: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -61,6 +62,7 @@ class AppConfig:
|
||||
output_dir: str = ""
|
||||
output_filename: str = "{author}_{from}_{to}.{ext}"
|
||||
output_default_format: str = "xlsx"
|
||||
report_no_time: bool = False
|
||||
email: EmailConfig = field(default_factory=EmailConfig)
|
||||
|
||||
@classmethod
|
||||
@@ -86,6 +88,7 @@ class AppConfig:
|
||||
"redmine",
|
||||
"period",
|
||||
"output",
|
||||
"report",
|
||||
"email",
|
||||
}
|
||||
for key in raw:
|
||||
@@ -106,7 +109,9 @@ class AppConfig:
|
||||
output_dir=cls._resolve_str(raw, "output", "dir"),
|
||||
output_filename=cls._resolve_str(raw, "output", "filename")
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -189,6 +194,7 @@ class AppConfig:
|
||||
cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт."
|
||||
),
|
||||
attach=cls._safe_bool(email_raw.get("attach"), True),
|
||||
html=cls._safe_bool(email_raw.get("html"), False),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -343,6 +349,23 @@ class Config:
|
||||
return cls._app.output_default_format or "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
|
||||
def get_default_date_range(cls) -> str:
|
||||
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
|
||||
@@ -387,7 +410,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.
|
||||
|
||||
- For a full calendar month → next full calendar month.
|
||||
|
||||
@@ -15,7 +15,9 @@ from .base import Formatter
|
||||
class ODTFormatter(Formatter):
|
||||
"""Форматтер для экспорта в 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.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
# Удаляем все текстовые параграфы из шаблона, оставляя только
|
||||
@@ -52,7 +58,9 @@ class ODTFormatter(Formatter):
|
||||
# Стиль ячеек
|
||||
cell_style_name = "TableCellStyle"
|
||||
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)
|
||||
doc.automaticstyles.addElement(cell_style)
|
||||
|
||||
@@ -100,7 +108,9 @@ class ODTFormatter(Formatter):
|
||||
# в остальных — covered-cell для валидности ODF (#13)
|
||||
if first_version_in_project and first_row_in_version:
|
||||
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)
|
||||
cell_project.addElement(p)
|
||||
row.addElement(cell_project)
|
||||
@@ -111,7 +121,9 @@ class ODTFormatter(Formatter):
|
||||
# в остальных — covered-cell для валидности ODF (#13)
|
||||
if first_row_in_version:
|
||||
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)
|
||||
cell_version.addElement(p)
|
||||
row.addElement(cell_version)
|
||||
|
||||
@@ -19,8 +19,12 @@ class XLSXFormatter(Formatter):
|
||||
и числовой столбец с часами для удобного суммирования.
|
||||
"""
|
||||
|
||||
_HEADER_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
|
||||
_TOTAL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
||||
_HEADER_FILL = PatternFill(
|
||||
start_color="D9E1F2", end_color="D9E1F2", fill_type="solid"
|
||||
)
|
||||
_TOTAL_FILL = PatternFill(
|
||||
start_color="FFF2CC", end_color="FFF2CC", fill_type="solid"
|
||||
)
|
||||
_BORDER = Border(
|
||||
left=Side(style="thin"),
|
||||
right=Side(style="thin"),
|
||||
@@ -40,7 +44,15 @@ class XLSXFormatter(Formatter):
|
||||
else:
|
||||
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)
|
||||
self._style_header_row(ws, headers)
|
||||
|
||||
@@ -89,7 +101,10 @@ class XLSXFormatter(Formatter):
|
||||
)
|
||||
self._style_total_row(ws, current_row, bold=False)
|
||||
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
|
||||
|
||||
@@ -99,7 +114,8 @@ class XLSXFormatter(Formatter):
|
||||
|
||||
if not self.no_time:
|
||||
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(
|
||||
[
|
||||
@@ -114,7 +130,10 @@ class XLSXFormatter(Formatter):
|
||||
)
|
||||
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
|
||||
start_row=current_row,
|
||||
start_column=1,
|
||||
end_row=current_row,
|
||||
end_column=5,
|
||||
)
|
||||
current_row += 1
|
||||
project_totals[project] = project_hours
|
||||
@@ -137,7 +156,9 @@ class XLSXFormatter(Formatter):
|
||||
]
|
||||
)
|
||||
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:
|
||||
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.fill = self._HEADER_FILL
|
||||
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:
|
||||
for col_idx in range(1, 8):
|
||||
@@ -190,7 +213,15 @@ class XLSXFormatter(Formatter):
|
||||
|
||||
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 col_idx, cell in enumerate(row, start=1):
|
||||
|
||||
143
redmine_reporter/mailer.py
Normal file
143
redmine_reporter/mailer.py
Normal 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
|
||||
@@ -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] = []
|
||||
prev_project: str = ""
|
||||
@@ -67,7 +69,9 @@ def build_grouped_report(
|
||||
time_text = ""
|
||||
|
||||
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(
|
||||
cast(
|
||||
@@ -122,7 +126,9 @@ def calculate_summary(
|
||||
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ def resolve_env_vars(value: str) -> str:
|
||||
var_name = match.group(1)
|
||||
env_value = os.environ.get(var_name)
|
||||
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 env_value
|
||||
|
||||
@@ -182,5 +184,7 @@ def save_period_to_config(
|
||||
|
||||
ensure_config_dir(path.parent)
|
||||
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)
|
||||
|
||||
@@ -62,7 +62,9 @@ def test_cli_returns_zero_on_no_entries(mock_fetch):
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_cli_config_error():
|
||||
"""Невалидный конфиг -- выход 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
|
||||
|
||||
|
||||
@@ -159,17 +161,6 @@ def test_cli_verbose_and_debug_flags_accepted(mock_fetch):
|
||||
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("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||
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_path = tmp_path / "custom.env"
|
||||
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",
|
||||
)
|
||||
mock_fetch.return_value = None
|
||||
@@ -259,7 +250,9 @@ def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
|
||||
"""CLI выводит понятное сообщение при таймауте."""
|
||||
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"])
|
||||
captured = capsys.readouterr()
|
||||
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)]
|
||||
|
||||
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_get_formatter.return_value = mock_formatter
|
||||
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}"
|
||||
|
||||
@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)
|
||||
def test_init_config_no_env_vars(self, tmp_path):
|
||||
"""Без переменных окружения --init-config создаёт скелет."""
|
||||
@@ -408,6 +418,21 @@ class TestInitConfig:
|
||||
assert data["redmine"]["url"] == ""
|
||||
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("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("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."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
@@ -559,7 +586,9 @@ class TestCommitFlag:
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
|
||||
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:
|
||||
mock_formatter = mock.MagicMock()
|
||||
@@ -594,7 +623,9 @@ class TestCommitFlag:
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
|
||||
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:
|
||||
mock_formatter = mock.MagicMock()
|
||||
@@ -641,7 +672,9 @@ class TestCommitFlag:
|
||||
@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_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."""
|
||||
import yaml
|
||||
|
||||
@@ -649,7 +682,9 @@ class TestCommitFlag:
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
|
||||
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:
|
||||
mock_formatter = mock.MagicMock()
|
||||
@@ -674,7 +709,9 @@ class TestCommitFlag:
|
||||
@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_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 сохраняет файл по шаблону из конфига."""
|
||||
import yaml
|
||||
|
||||
@@ -713,7 +750,9 @@ class TestCommitFlag:
|
||||
@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_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."""
|
||||
import yaml
|
||||
|
||||
@@ -721,7 +760,9 @@ class TestCommitFlag:
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
|
||||
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:
|
||||
mock_formatter = mock.MagicMock()
|
||||
@@ -752,7 +793,9 @@ class TestCommitFlag:
|
||||
mock_fetch.return_value = []
|
||||
|
||||
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(
|
||||
[
|
||||
@@ -765,3 +808,560 @@ class TestCommitFlag:
|
||||
)
|
||||
assert code == 0
|
||||
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
|
||||
|
||||
@@ -157,7 +157,9 @@ def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
|
||||
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")
|
||||
def test_fetch_uses_custom_verify_path(mock_redmine_class):
|
||||
mock_redmine = mock_redmine_class.return_value
|
||||
@@ -390,7 +392,9 @@ def test_fetch_mounts_retry_adapter(mock_redmine_class):
|
||||
assert "http://" in prefixes
|
||||
|
||||
# Проверяем 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
|
||||
assert max_retries.total == 3
|
||||
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", "")
|
||||
call_chunks.append(ids_str)
|
||||
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
|
||||
|
||||
@@ -473,7 +479,9 @@ def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
|
||||
mock_issue2.project = "P"
|
||||
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 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("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-логика)."""
|
||||
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.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
|
||||
|
||||
@@ -531,7 +543,9 @@ def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
|
||||
mock_issue1.status = "New"
|
||||
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 len(result) == 1
|
||||
@@ -563,7 +577,9 @@ def test_dedup_entries_without_created_on_are_kept(mock_redmine_class):
|
||||
mock_issue1.status = "New"
|
||||
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 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.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
|
||||
|
||||
@@ -665,7 +683,9 @@ def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
|
||||
mock_issue_akiy.status = "New"
|
||||
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 len(result) == 2
|
||||
|
||||
@@ -5,7 +5,12 @@ from unittest import mock
|
||||
|
||||
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(
|
||||
@@ -328,7 +333,9 @@ class TestConfigYamlFallback:
|
||||
|
||||
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):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
@@ -501,7 +508,9 @@ class TestComputeNextPeriod:
|
||||
def test_datetime_precision_moves_by_seconds(self):
|
||||
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 nt == "2026-06-30T15:00:01"
|
||||
|
||||
@@ -557,3 +566,141 @@ class TestDefaultDateRangeWithLastUsed:
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
result = Config.get_default_date_range()
|
||||
assert result == "2026-04-01--2026-04-15"
|
||||
|
||||
|
||||
class TestGetEmailConfig:
|
||||
"""Tests for Config.get_email_config()."""
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_email_config_from_yaml(self):
|
||||
"""get_email_config() возвращает EmailConfig из YAML."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"email:\n"
|
||||
" smtp:\n"
|
||||
" host: smtp.example.com\n"
|
||||
" port: 587\n"
|
||||
" user: bot@example.com\n"
|
||||
" password: secret\n"
|
||||
" tls: true\n"
|
||||
" from: bot@example.com\n"
|
||||
" to:\n"
|
||||
" - boss@example.com\n"
|
||||
)
|
||||
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
cfg = Config.get_email_config()
|
||||
|
||||
assert cfg is not None
|
||||
assert cfg.smtp.host == "smtp.example.com"
|
||||
assert cfg.smtp.port == 587
|
||||
assert cfg.smtp.user == "bot@example.com"
|
||||
assert cfg.smtp.password == "secret"
|
||||
assert cfg.smtp.tls is True
|
||||
assert cfg.from_ == "bot@example.com"
|
||||
assert cfg.to == ["boss@example.com"]
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_email_config_returns_none_when_no_yaml(self):
|
||||
"""Без YAML-конфига get_email_config() возвращает None."""
|
||||
Config._app = None
|
||||
assert Config.get_email_config() is None
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_email_config_returns_none_when_no_host(self):
|
||||
"""С YAML но без smtp.host — возвращает None."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("email:\n smtp:\n host: ''\n")
|
||||
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
assert Config.get_email_config() is None
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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 как полноценного пакета --
|
||||
@@ -171,7 +173,11 @@ def _simulate_missing_odfpy():
|
||||
|
||||
saved = {}
|
||||
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)
|
||||
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)
|
||||
|
||||
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"
|
||||
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)
|
||||
|
||||
from odf.table import CoveredTableCell
|
||||
|
||||
303
tests/test_mailer.py
Normal file
303
tests/test_mailer.py
Normal 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": "2ч",
|
||||
"hours": 2.0,
|
||||
}
|
||||
]
|
||||
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||
|
||||
alternatives = [
|
||||
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
|
||||
]
|
||||
assert len(alternatives) == 1
|
||||
|
||||
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
|
||||
assert len(html_parts) == 1
|
||||
|
||||
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||
assert len(plain_parts) == 1
|
||||
|
||||
def test_no_html_part_when_html_false(self, tmp_path):
|
||||
"""При email.html: false письмо содержит только plain-text."""
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config(html=False)
|
||||
msg = build_message(cfg, str(report), "A", "P", [])
|
||||
|
||||
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
|
||||
assert len(html_parts) == 0
|
||||
|
||||
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||
assert len(plain_parts) == 1
|
||||
|
||||
def test_html_part_contains_report_data(self, tmp_path):
|
||||
"""HTML-часть содержит данные отчёта."""
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config(html=True)
|
||||
rows = [
|
||||
{
|
||||
"project": "Project X",
|
||||
"version": "2.0",
|
||||
"issue_id": 42,
|
||||
"subject": "Important task",
|
||||
"status_ru": "В работе",
|
||||
"time_text": "3ч 30м",
|
||||
"hours": 3.5,
|
||||
}
|
||||
]
|
||||
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||
|
||||
html_part = [p for p in msg.walk() if p.get_content_type() == "text/html"][0]
|
||||
payload = html_part.get_payload(decode=True).decode("utf-8")
|
||||
assert "Project X" in payload
|
||||
assert "Important task" in payload
|
||||
|
||||
def test_plain_text_before_html_in_alternative(self, tmp_path):
|
||||
"""В multipart/alternative plain-text идёт перед HTML."""
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config(html=True)
|
||||
rows = [
|
||||
{
|
||||
"project": "P",
|
||||
"version": "V",
|
||||
"issue_id": 1,
|
||||
"subject": "S",
|
||||
"status_ru": "В работе",
|
||||
"time_text": "1ч",
|
||||
"hours": 1.0,
|
||||
}
|
||||
]
|
||||
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||
|
||||
alternative = [
|
||||
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
|
||||
][0]
|
||||
payloads = alternative.get_payload()
|
||||
assert payloads[0].get_content_type() == "text/plain"
|
||||
assert payloads[1].get_content_type() == "text/html"
|
||||
|
||||
|
||||
class TestSendReport:
|
||||
"""Тесты отправки письма."""
|
||||
|
||||
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"
|
||||
@@ -217,7 +217,9 @@ class TestSavePeriodToConfig:
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
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:
|
||||
data = yaml.safe_load(fh)
|
||||
@@ -233,10 +235,12 @@ class TestSavePeriodToConfig:
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
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:
|
||||
data = yaml.safe_load(fh)
|
||||
@@ -251,10 +255,12 @@ class TestSavePeriodToConfig:
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
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:
|
||||
data = yaml.safe_load(fh)
|
||||
@@ -299,7 +305,9 @@ class TestSavePeriodToConfig:
|
||||
" 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:
|
||||
data = yaml.safe_load(fh)
|
||||
@@ -314,7 +322,9 @@ class TestSavePeriodToConfig:
|
||||
|
||||
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
|
||||
assert mode == 0o600
|
||||
|
||||
Reference in New Issue
Block a user