5 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
3ce4f408ed chore: bump version to 2.0.0
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
2026-09-30 12:37:00 +07:00
Кокос Артем Николаевич
27d21ef639 refactor: период через period.from вместо last_used; дедуп удалён
Удалены last_used, precision, dynamic и дедупликация по created_on/updated_on. Период задаётся одним полем period.from (env FROM_DATE), окно -- от этой даты до сегодня. После успешного --commit/--send начало окна сдвигается на конец отчёта; datetime-границы --date приводятся к полным дням. Схема конфига ломающая: старые ключи игнорируются, миграция описана в документации.
2026-09-30 12:37:00 +07:00
Кокос Артем Николаевич
db069dfb4b chore: bump version to 1.12.1
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
2026-09-15 15:43:11 +07:00
Кокос Артем Николаевич
4a9a21624d fix: sliding period window for datetime precision
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
After --commit with precision=datetime, last_used held from==to==now,
so the next default period degenerated to a single second and showed
no new time entries. Now --commit stores the real report window start
and get_default_date_range() returns last_used.to..now for
datetime+dynamic mode.

Closes #70
2026-09-15 15:40:20 +07:00
Кокос Артем Николаевич
35fa585bd0 feat: add email confirmation prompt before sending report
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
Require user confirmation (y/N) before sending email via --send.
Shows from/to/cc/bcc/file info. Skips send on anything except y/yes.

Version bump: 1.11.1 → 1.12.0
2026-07-26 16:15:10 +07:00
13 changed files with 961 additions and 1282 deletions

View File

@@ -19,9 +19,9 @@ CLI-инструмент для генерации отчётов по зада
- Группировка задач по проекту и версии, перевод статусов на русский язык.
- Вывод в консоль (таблица или компактный вид) и экспорт в ODT, CSV, Markdown, HTML, JSON, XLSX.
- Разбивка времени по типам активности (`--by-activity`), сводка (`--summary`), скрытие времени (`--no-time`).
- Гибкий выбор периода: `--date`, переменные окружения, YAML-конфиг, по умолчанию — текущий месяц.
- `--commit`: сохранение отчёта в файл и фиксация периода в конфиге для следующего запуска.
- `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо).
- Гибкий выбор периода: `--date`, env `FROM_DATE`, YAML `period.from`, по умолчанию — текущий месяц.
- `--commit`: сохранение отчёта в файл и авто-сдвиг начала периода в конфиге для следующего запуска.
- `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо) с авто-сдвигом начала периода при успешной отправке.
- YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md).
## Установка
@@ -55,7 +55,7 @@ vim ~/.config/redmine-reporter/config.yml
# Отчёт за текущий месяц в консоль
redmine-reporter
# Сохранить в файл и зафиксировать период для следующего запуска
# Сохранить в файл и сдвинуть начало периода для следующего запуска
redmine-reporter --commit
```

View File

@@ -6,11 +6,10 @@
- [YAML-конфиг](#yaml-конфиг)
- [Структура](#структура)
- [`redmine.verify_ssl` — проверка TLS-сертификата](#redmineverify_ssl--проверка-tls-сертификата)
- [`period.precision` — точность периода](#periodprecision--точность-периода)
- [`period.default_to` — необязательное окончание периода](#perioddefault_to--необязательное-окончание-периода)
- [Период по умолчанию — текущий месяц](#период-по-умолчанию--текущий-месяц)
- [`period.from` — начало окна отчёта](#periodfrom--начало-окна-отчёта)
- [`--date` — формат диапазона](#--date--формат-диапазона)
- [`--commit` — автофиксация периода](#--commit--автофиксация-периода)
- [Авто-сдвиг начала периода](#авто-сдвиг-начала-периода)
- [Миграция старых ключей периода](#миграция-старых-ключей-периода)
- [`email` — настройка отправки по почте](#email--настройка-отправки-по-почте)
- [`--send` — отправка отчёта по email](#--send--отправка-отчёта-по-email)
- [`output` — путь и имя файла по умолчанию](#output--путь-и-имя-файла-по-умолчанию)
@@ -61,14 +60,7 @@ redmine:
verify_ssl: true
period:
precision: date
default_from: "2026-06-01"
# default_to можно не указывать — тогда конец периода будет сегодня
default_to: "2026-06-30"
dynamic: false
last_used:
from: "2026-06-30T09:00:00"
to: "2026-06-30T12:00:00"
from: "2026-06-01"
output:
dir: ~/reports
@@ -113,91 +105,111 @@ Debian/Ubuntu. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env ра
одинаково — оба включают проверку по системному хранилищу CA на любой ОС
(через truststore), без привязки к конкретному пути.
### `period.precision` — точность периода
### `period.from` — начало окна отчёта
Определяет, как вычисляется следующий период после фиксации:
- `date` (по умолчанию) — период с точностью до дня. Следующий запуск (после `--commit`) начинается со следующего дня.
- `datetime` — период с точностью до секунды. При повторном запуске time entries, у которых `created_on` или `updated_on` раньше `last_used.to`, исключаются: запись сохраняется, только если **оба** поля не раньше cutoff (AND-логика). Записи без дат (оба поля отсутствуют) сохраняются; если задано только одно поле, проверяется оно. Это предотвращает дублирование при отправке отчёта внутри рабочего дня. Cutoff применяется всегда, когда вычислен (`precision: datetime` и задан `last_used.to`) — в том числе при явном `--date`.
`last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется.
### `period.default_to` — необязательное окончание периода
Если `period.default_to` не задан, а `period.default_from` задан, инструмент
использует сегодняшнюю дату в качестве конца периода.
Единственный ключ периода в YAML. Дата в формате `YYYY-MM-DD`:
```yaml
period:
default_from: "2026-07-01"
# default_to отсутствует → конец периода = сегодня
from: "2026-06-01"
```
Это предотвращает устаревание периода, когда отчёт генерируется автоматически
(`--send`, `--commit`) без явного `--date`.
Окно отчёта — `from..сегодня`: конец окна всегда сегодняшняя дата и отдельно
не настраивается. Переменная окружения `FROM_DATE` задаёт то же начало и имеет
приоритет над YAML:
Аналогично работает `DEFAULT_TO_DATE`: если переменная не задана, а
`DEFAULT_FROM_DATE` задана, конец периода = сегодня.
```
--date > FROM_DATE (env) > period.from (YAML) > 1-е число текущего месяца
```
Обратное не работает: `period.default_to` без `period.default_from` (и
`DEFAULT_TO_DATE` без `DEFAULT_FROM_DATE`) игнорируется — период определяется
остальными источниками.
Разрешение начала окна:
### Период по умолчанию — текущий месяц
| Условие | Начало окна |
|---|---|
| `from` не задан (`FROM_DATE` и `period.from` пусты) | 1-е число текущего месяца |
| `from` — корректная дата | `from` |
| `from` позже сегодняшней даты | сегодня (окно «сегодня--сегодня») |
| `from` некорректна (не `YYYY-MM-DD`) | warning в stderr + 1-е число текущего месяца |
Если период не задан ни одним из источников (`--date`, `DEFAULT_FROM_DATE`,
`period.default_from`), отчёт строится за текущий месяц: начало периода —
1-е число текущего месяца, конец — сегодняшняя дата. Период вычисляется
на момент запуска и не хранится в коде или конфиге.
Граничный день (день коммита) становится началом следующего окна и потому
перечитывается: уже отправленные записи этого дня повторятся в следующем
отчёте. Это осознанное следствие отсутствия дедупликации; зато поздние
записи, добавленные в Redmine в день коммита после отправки, не теряются.
Повторный запуск `--commit`/`--send` в тот же день даёт окно
«сегодня--сегодня».
`period.from` записывается автоматически при `--commit`/`--send`; вручную
редактировать не требуется.
### `--date` — формат диапазона
Флаг `--date` принимает два формата:
Флаг `--date` задаёт диапазон явно и переопределяет все остальные источники.
Каждая граница — дата `YYYY-MM-DD` или datetime `YYYY-MM-DDTHH:MM:SS`:
- Даты: `YYYY-MM-DD--YYYY-MM-DD` (например, `2026-06-01--2026-06-30`).
- Datetime с секундами: `YYYY-MM-DDTHH:MM:SS--YYYY-MM-DDTHH:MM:SS`
(например, `2026-06-30T09:00:00--2026-06-30T12:00:00`).
- `2026-06-01--2026-06-30`;
- `2026-06-30T09:00:00--2026-06-30T12:00:00`.
Обе границы должны быть в одном формате — смешанная точность отвергается.
Время в границах отбрасывается — окно всегда состоит из полных дней, так как
гранулярность Redmine — день. Границы можно смешивать (дата + datetime).
Начало позже конца (`start > end`) — ошибка.
### `--commit` — автофиксация периода
### Авто-сдвиг начала периода
Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода.
`--commit` и `--send` генерируют отчёт, сохраняют его в файл (по `--output`
или по шаблону `output.dir`/`output.filename`) и сдвигают начало окна.
`--send` дополнительно отправляет отчёт по email (см.
[ниже](#--send--отправка-отчёта-по-email)).
**Что делает:**
После успешного `--commit` (файл сохранён) или реальной отправки `--send`
(подтверждение «y» + успешная отправка) в конфиг пишется
`period.from = <конец окна отчёта>`. Следующий запуск без `--date` начинает
окно с граничного дня последнего отчёта.
1. Генерирует отчёт как обычно.
2. Сохраняет отчёт в файл:
- Если указан `--output` — по явному пути.
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
3. Записывает `period.last_used.from` / `period.last_used.to` в YAML-конфиг.
4. При `period.precision: datetime` сохраняет текущий момент времени (ISO с секундами).
5. При `period.precision: date` сохраняет даты периода.
Сдвиг НЕ происходит:
**Поведение `period.dynamic`:**
- если за период нет time entries (отчёт пуст — выход до сохранения);
- при отказе от подтверждения отправки в `--send` (ответ не «y»/«yes»);
- при ошибке SMTP в `--send` (соединение, аутентификация, таймаут);
- при ошибке сохранения файла отчёта.
- `dynamic: true` — следующий запуск (без `--date`) вычисляет период от `last_used`:
- Полный календарный месяц → следующий полный месяц.
- Произвольный диапазон → та же длительность, начиная со дня после `last_used.to`.
- `dynamic: false` — `--commit` перезаписывает `default_from`/`default_to` на использованный период.
Исключение: при явном `--commit` (сочетание `--send --commit`) отказ от
подтверждения отправки сдвиг не отменяет: окно всё равно сдвигается по
`--commit`.
**Нюанс:** при `--commit` YAML-файл перезаписывается целиком через `yaml.dump` —
пользовательские комментарии и ручное форматирование в файле теряются.
При сочетании `--send --commit` ошибка SMTP подавляет и сдвиг по `--commit`:
файл остаётся сохранён, но начало периода не двигается.
**Примеры:**
Примеры:
```bash
# Июнь 2026 → следующий запуск (без --date) → июль 2026
# Окно 1--30 июня → следующий запуск (без --date) начнётся с 30 июня
redmine-reporter --date 2026-06-01--2026-06-30 --commit
# Произвольный диапазон: 15-20 июня → следующий запуск → 21-26 июня
# Произвольный диапазон 15--20 июня → следующее окно с 20 июня
redmine-reporter --date 2026-06-15--2026-06-20 --commit
# С datetime-точностью: повторный запуск не дублирует записи
redmine-reporter --commit
# Отправить и сдвинуть окно (только при подтверждении «y» и успехе)
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
```
`--commit`/`--send` перезаписывают только `period.from`; остальные секции и
ключи YAML сохраняются. Файл перезаписывается через `yaml.dump` —
пользовательские комментарии и ручное форматирование теряются.
### Миграция старых ключей периода
Старые ключи периода больше не читаются и тихо игнорируются при загрузке —
их можно удалить из YAML:
- `period.last_used` (включая `from`/`to`);
- `period.precision`;
- `period.dynamic`;
- `period.default_from` / `period.default_to`.
Env `DEFAULT_FROM_DATE` / `DEFAULT_TO_DATE` заменены на `FROM_DATE`; конец
периода больше не настраивается (всегда сегодня). Дедупликация time entries
по `created_on`/`updated_on` удалена.
### `email` — настройка отправки по почте
Секция `email` используется флагом `--send`. Если секция не настроена или `smtp.host`
@@ -266,8 +278,10 @@ email:
### `--send` — отправка отчёта по email
Флаг `--send` отправляет сгенерированный отчёт через SMTP сразу после сохранения
в файл. Требует настроенную секцию `email` в YAML-конфиге.
Флаг `--send` сохраняет отчёт в файл, отправляет его через SMTP и сдвигает
начало периода при подтверждении и успешной отправке (см.
[Авто-сдвиг](#авто-сдвиг-начала-периода)). Требует настроенную
секцию `email` в YAML-конфиге.
**Что делает:**
@@ -275,11 +289,14 @@ email:
2. Сохраняет отчёт в файл:
- Если указан `--output` — по явному пути.
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
3. Формирует MIME-письмо:
3. Запрашивает подтверждение: «Отправить? [y/N]». Ответ «y»/«yes» продолжает,
иначе отправка отменяется (без сдвига периода).
4. Формирует MIME-письмо:
- Тема, plain-text тело и вложение (если `attach: true`).
- При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта.
4. Отправляет через SMTP (таймаут 30 секунд). STARTTLS включается только при
5. Отправляет через SMTP (таймаут 30 секунд). STARTTLS включается только при
`email.smtp.tls: true`; аутентификация — только если задан `email.smtp.user`.
6. При успешной отправке сдвигает начало периода.
**Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся
на диске, данные не теряются.
@@ -302,7 +319,7 @@ 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
```
@@ -312,7 +329,7 @@ redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
|---|---|
| `--send` | Сохранить по шаблону → отправить |
| `--send --output X` | Сохранить в X → отправить |
| `--send --commit` | Сохранить → отправить → зафиксировать период |
| `--send --commit` | Сохранить → подтвердить → отправить → сдвинуть начало периода |
| `--send` с `email.html: true` | Письмо с plain-text + HTML-таблицей |
| `--send` без `email` в конфиге | Ошибка, exit 1 |
| `--send` при ошибке SMTP | Файл сохранён, ошибка в stderr, exit 1 |
@@ -429,7 +446,7 @@ report:
2. `--output xlsx` (bare format) → путь формируется как `output.dir / output.filename` с подстановкой `{ext}` = bare format и дат из периода.
3. `--output /path/report` (без расширения) → дописывается `.output.default_format`.
4. `--output /path/report.csv` (с расширением) → используется как есть.
5. `--output /path/report.xyz` (неизвестное расширение) → используется как есть, форматтер выбирается по расширению.
5. `--output /path/report.xyz` (неизвестное расширение) → ошибка «Неизвестный формат файла», exit 1.
## Миграция с `.env` на YAML
@@ -463,9 +480,8 @@ vim ~/.config/redmine-reporter/config.yml
| `--init-config --force` | Перезаписать существующий YAML |
| `--config-path PATH` | Путь к YAML-конфигу: загрузка при запуске, запись при `--init-config` и `--commit` (по умолчанию `~/.config/redmine-reporter/config.yml`) |
Если `DEFAULT_TO_DATE` не задана, а `DEFAULT_FROM_DATE` задана, сгенерированный
YAML будет содержать пустое `default_to`, и при запуске инструмент использует
сегодняшнюю дату.
Если задана `FROM_DATE`, сгенерированный YAML будет содержать `period.from` с
этим значением.
### Проверка после миграции
@@ -507,14 +523,15 @@ rm ~/.config/redmine-reporter/config.yml
## `.env` (legacy)
Для обратной совместимости `.env` продолжает работать без изменений.
Для обратной совместимости `.env` продолжает работать как раньше, кроме ключей
периода: `DEFAULT_FROM_DATE` заменён на `FROM_DATE`, `DEFAULT_TO_DATE` удалён
(см. [Миграция старых ключей периода](#миграция-старых-ключей-периода)).
```ini
REDMINE_URL=https://red.eltex.loc
REDMINE_API_KEY=your-api-key
REDMINE_AUTHOR=Кокос А.А.
DEFAULT_FROM_DATE=2026-06-01
DEFAULT_TO_DATE=2026-06-30
FROM_DATE=2026-06-01
```
Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц

View File

@@ -9,7 +9,7 @@
- [Экспорт в файл](#экспорт-в-файл)
- [Содержимое отчёта](#содержимое-отчёта)
- [Отправка по email](#отправка-по-email)
- [Ежемесячный цикл с --commit](#ежемесячный-цикл-с---commit)
- [Скользящее окно периода](#скользящее-окно-периода)
- [Справочник флагов](#справочник-флагов)
- [Устранение неполадок](#устранение-неполадок)
- [См. также](#см-также)
@@ -68,16 +68,15 @@ redmine-reporter --init-config --config-path /etc/redmine-reporter/config.yml
## Выбор периода
Источник периода выбирается по приоритету (от высшего к низшему):
Окно отчёта — диапазон дат `from..to`. Конец (`to`) всегда сегодняшняя дата,
если диапазон не задан явно через `--date`.
1. `--date` — явный диапазон.
2. `DEFAULT_FROM_DATE` (env/.env) — конец: `DEFAULT_TO_DATE` или сегодня. `DEFAULT_TO_DATE` без `DEFAULT_FROM_DATE` игнорируется.
3. `period.dynamic: true` + `period.last_used` в YAML — следующий период после `last_used`:
- последний период — полный календарный месяц → следующий полный месяц;
- произвольный диапазон → та же длительность, начиная со дня после `last_used.to`;
- при `precision: datetime` → та же длительность, сдвинутая на 1 секунду после `last_used.to`.
4. `period.default_from` в YAML — конец: `default_to` или сегодня. `default_to` без `default_from` игнорируется.
5. Ничего не задано → текущий месяц: с 1-го числа по сегодня.
Приоритет начала окна (от высшего к низшему):
1. `--date` — явный диапазон (полностью переопределяет окно).
2. `FROM_DATE` (env) — начало; конец — сегодня.
3. `period.from` (YAML) — начало; конец — сегодня.
4. Ничего не задано — текущий месяц: с 1-го числа по сегодня.
Форматы `--date`:
@@ -85,11 +84,18 @@ redmine-reporter --init-config --config-path /etc/redmine-reporter/config.yml
# Даты
redmine-reporter --date 2026-06-01--2026-06-30
# Дата-время
# Дата-время (границы флорятся до полных дней)
redmine-reporter --date 2026-06-01T09:00:00--2026-06-01T18:00:00
```
Смешанная точность (дата + дата-время в одном диапазоне) и диапазон, где начало позже конца, отвергаются с ошибкой.
Границы с временем округляются до дат — отчёт строится за полные дни диапазона.
Границы можно смешивать (дата + дата-время). Начало позже конца
(`start > end`) — ошибка.
Краевые случаи:
- `from` позже сегодняшней даты — окно «сегодня--сегодня».
- `from` некорректна (не `YYYY-MM-DD`) — warning + текущий месяц.
## Отчёт за другого пользователя
@@ -206,6 +212,8 @@ redmine-reporter --send --commit
Факты:
- Файл отчёта сохраняется на диск ДО отправки: при ошибке SMTP файл остаётся на месте.
- Перед отправкой запрашивается подтверждение «Отправить? [y/N]»; ответ «y»/«yes» продолжает, иначе отправка отменяется (без сдвига периода, если не задан явный `--commit`).
- При успешной отправке начало периода сдвигается (см. [Скользящее окно периода](#скользящее-окно-периода)); при отмене (если не задан явный `--commit`) или ошибке SMTP — без сдвига.
- Секция `email` отсутствует или `smtp.host` пуст → ошибка «Email не настроен. Добавьте секцию 'email' в конфиг...», exit 1.
- `email.html: true` → письмо `multipart/alternative`: plain-text + HTML-таблица отчёта в теле.
- Файл прикрепляется при `attach: true`; MIME-тип определяется по расширению, неизвестное → `application/octet-stream`.
@@ -220,41 +228,42 @@ redmine-reporter --send --commit
- «Ошибка отправки письма: ...»
- «Не удалось подключиться к SMTP-серверу host:port»
## Ежемесячный цикл с --commit
## Скользящее окно периода
`--commit` сохраняет отчёт в файл (по `--output` или по шаблону `output.dir`/`output.filename`) и записывает `period.last_used` в YAML-конфиг.
Что пишется:
- `precision: datetime` → `last_used.from` = `last_used.to` = текущий момент UTC.
- `precision: date` → фактические `from`/`to` использованного периода.
- `dynamic: true` → `default_from`/`default_to` не меняются.
- `dynamic: false` → дополнительно перезаписываются `default_from`/`default_to`.
После успешного `--commit` (файл сохранён) или реальной отправки `--send`
(подтверждение «y» + успех) окно отчёта сдвигается вперёд: в конфиг пишется
`period.from = <конец окна>`. Следующий запуск без `--date`
начинается с граничного дня последнего отчёта.
Типичный цикл:
```bash
# 1. Отчёт за июнь 2026: сохранить файл, зафиксировать период
# 1. Отчёт за июнь 2026: сохранить, отправить, сдвинуть начало на 30 июня
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
# 2. Следующий запуск без --date возьмёт июль 2026 автоматически
# (при dynamic: true и полном месяце)
# 2. Следующий запуск без --date берёт окно с 30 июня по сегодня
redmine-reporter --send --commit
```
Защита от дублей при `precision: datetime`:
Граничный день: начало следующего окна совпадает с днём коммита, поэтому этот
день перечитывается. Записи дня коммита, уже попавшие в прошлый отчёт,
повторятся (дедупликации по `created_on`/`updated_on` нет — это осознанное
следствие), зато поздние записи, добавленные в Redmine в этот день после
отправки, не теряются.
- Если в YAML есть `last_used.to` (cutoff), time entry сохраняется в отчёт, только если `created_on` И `updated_on` оба >= cutoff; если хотя бы одно поле раньше cutoff — запись исключается.
- Записи, у которых оба поля (`created_on`, `updated_on`) отсутствуют, сохраняются; если задано только одно поле, решает оно.
- Фильтр применяется всегда при `precision: datetime`, в том числе при явном `--date`.
Повторный запуск `--commit`/`--send` в тот же день даёт окно
«сегодня--сегодня», которое повторно включает уже отправленные записи этого
дня.
Важно: `--commit` перезаписывает YAML через `yaml.dump` — пользовательские комментарии в файле теряются.
Важно: `--commit`/`--send` перезаписывают только `period.from`; остальные
ключи YAML сохраняются, но пользовательские комментарии теряются
(перезапись через `yaml.dump`).
## Справочник флагов
| Флаг | Описание |
| --- | --- |
| `--date` | Диапазон периода: `YYYY-MM-DD--YYYY-MM-DD` или `YYYY-MM-DDTHH:MM:SS--YYYY-MM-DDTHH:MM:SS`. Смешанная точность и `start > end` отвергаются. |
| `--date` | Явный диапазон: границы — дата `YYYY-MM-DD` или datetime `YYYY-MM-DDTHH:MM:SS`. Время отбрасывается (полные дни), границы можно смешивать; `start > end` — ошибка. |
| `--compact` | Компактный текстовый вывод в консоль вместо таблицы. |
| `--output` | Путь к файлу (`.odt`/`.csv`/`.md`/`.html`/`.json`/`.xlsx`) или bare-формат — тогда путь берётся из YAML-шаблона; без флага — вывод в консоль (stdout). |
| `--author` | Переопределить имя автора отчёта. |
@@ -273,8 +282,8 @@ redmine-reporter --send --commit
| `--init-config` | Сгенерировать YAML-конфиг из текущего `.env`/окружения и выйти. |
| `--force` | Перезаписать существующий YAML-конфиг (только с `--init-config`). |
| `--config-path` | Путь к YAML-конфигу: загрузка, запись `--init-config` и `--commit`. По умолчанию `~/.config/redmine-reporter/config.yml`. |
| `--commit` | Сохранить отчёт в файл и зафиксировать период (`period.last_used`) в YAML. |
| `--send` | Отправить отчёт по email после сохранения файла. |
| `--commit` | Сохранить отчёт в файл и сдвинуть начало периода (`period.from` = конец окна) в YAML. |
| `--send` | Отправить отчёт по email после сохранения файла; при подтверждении и успехе сдвинуть начало периода. |
## Устранение неполадок

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import logging
import os
import re
import sys
from datetime import datetime, timezone
from datetime import datetime
from pathlib import Path
from typing import List, Optional
@@ -39,35 +39,38 @@ def _sanitize_error_text(text: str) -> str:
return text
def _parse_boundary(value: str) -> datetime:
"""Разбирает одну границу диапазона как дату или datetime без timezone."""
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
fmt = "%Y-%m-%d"
elif re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", value):
fmt = "%Y-%m-%dT%H:%M:%S"
else:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try:
return datetime.strptime(value, fmt)
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
def parse_date_range(date_arg: str) -> tuple[str, str]:
"""Возвращает границы диапазона в виде дат YYYY-MM-DD.
Каждая граница может быть датой или datetime; время отбрасывается
(границы окна -- целые дни), поэтому смешение форматов допустимо.
"""
if "--" not in date_arg:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
parts = date_arg.split("--", 1)
from_date, to_date = parts[0].strip(), parts[1].strip()
date_pattern = r"\d{4}-\d{2}-\d{2}"
datetime_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
if re.fullmatch(date_pattern, from_date) and re.fullmatch(date_pattern, to_date):
fmt = "%Y-%m-%d"
elif re.fullmatch(datetime_pattern, from_date) and re.fullmatch(
datetime_pattern, to_date
):
fmt = "%Y-%m-%dT%H:%M:%S"
else:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
start = _parse_boundary(from_date)
end = _parse_boundary(to_date)
try:
start = datetime.strptime(from_date, fmt)
end = datetime.strptime(to_date, fmt)
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
if start > end:
if start.date() > end.date():
raise ValueError("Date range start must be less than or equal to end")
if fmt == "%Y-%m-%d":
return start.date().isoformat(), end.date().isoformat()
return start.isoformat(), end.isoformat()
def _run_init_config(config_path: str, force: bool) -> int:
@@ -99,10 +102,7 @@ def _run_init_config(config_path: str, force: bool) -> int:
"verify_ssl": True,
},
"period": {
"precision": "date",
"default_from": os.getenv("DEFAULT_FROM_DATE", "").strip(),
"default_to": os.getenv("DEFAULT_TO_DATE", "").strip(),
"dynamic": False,
"from": os.getenv("FROM_DATE", "").strip(),
},
"output": {
"dir": "",
@@ -155,29 +155,6 @@ def _run_init_config(config_path: str, force: bool) -> int:
return 0
def _compute_dedup_cutoff() -> Optional[datetime]:
"""Compute deduplication cutoff from config.
If period.precision == 'datetime' and period.last_used.to is set,
return the datetime to filter out entries already accounted for.
Otherwise return None.
"""
if Config.get_period_precision() != "datetime":
return None
last_to = Config.get_last_used_to()
if not last_to:
return None
try:
dt = datetime.fromisoformat(last_to.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except (ValueError, TypeError):
return None
def _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool:
"""Возвращает финальное значение no_time.
@@ -201,10 +178,11 @@ def _save_and_maybe_send(
to_date: str,
no_time: bool,
do_send: bool,
) -> int:
) -> tuple[int, bool]:
"""Сохраняет отчёт в файл и опционально отправляет по email.
Returns 0 on success, 1 on error.
Returns (exit_code, sent): sent отражает факт реальной отправки письма,
чтобы main мог отличить отказ от подтверждения от успешной отправки.
"""
output_ext = os.path.splitext(output_arg)[1].lower()
@@ -213,7 +191,7 @@ def _save_and_maybe_send(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr,
)
return 1
return 1, False
formatter = get_formatter_by_extension(
output_ext,
@@ -236,7 +214,7 @@ def _save_and_maybe_send(
f"Поддерживаются: {known_exts}",
file=sys.stderr,
)
return 1
return 1, False
try:
formatter.save(rows, output_arg)
@@ -244,7 +222,7 @@ def _save_and_maybe_send(
except Exception as e:
fmt = output_ext.lstrip(".").upper()
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
return 1
return 1, False
if do_send:
email_config = Config.get_email_config()
@@ -254,7 +232,20 @@ def _save_and_maybe_send(
"(~/.config/redmine-reporter/config.yml).",
file=sys.stderr,
)
return 1
return 1, False
print("\n📧 Готово к отправке:")
print(f" From: {email_config.from_}")
print(f" To: {', '.join(email_config.to)}")
if email_config.cc:
print(f" Cc: {', '.join(email_config.cc)}")
if email_config.bcc:
print(f" Bcc: {', '.join(email_config.bcc)}")
print(f" File: {output_arg}")
response = input("Отправить? [y/N]: ").strip().lower()
if response not in ("y", "yes"):
print("⏭️ Отправка отменена.")
return 0, False
try:
send_report(
@@ -267,9 +258,10 @@ def _save_and_maybe_send(
print(f"📧 Report sent to {', '.join(email_config.to)}")
except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
return 1
return 1, False
return 0, True
return 0
return 0, False
def main(argv: Optional[List[str]] = None) -> int:
@@ -350,12 +342,12 @@ def main(argv: Optional[List[str]] = None) -> int:
parser.add_argument(
"--commit",
action="store_true",
help="Save used period as last_used in YAML config and auto-commit to file",
help="Save the report and advance period start to its end date in YAML config",
)
parser.add_argument(
"--send",
action="store_true",
help="Send generated report via email after saving (requires email section in config)",
help="Send generated report via email after saving (requires email section in config); on success advance period start to its end date",
)
args = parser.parse_args(argv)
@@ -429,7 +421,6 @@ def main(argv: Optional[List[str]] = None) -> int:
to_date,
user_id=args.user_id or args.user_login or args.user_name,
by_activity=args.by_activity,
dedup_before=_compute_dedup_cutoff(),
)
except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
@@ -472,6 +463,9 @@ def main(argv: Optional[List[str]] = None) -> int:
activity = key.split(":", 1)[1]
print(f" [{activity}]: {value}h", file=sys.stderr)
save_ok = False
report_sent = False
if args.output or args.commit:
if args.output:
output_arg = resolve_output_path(
@@ -502,7 +496,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
return 1
ret = _save_and_maybe_send(
ret, report_sent = _save_and_maybe_send(
rows,
output_arg,
author=Config.get_author(args.author),
@@ -513,6 +507,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
if ret != 0:
return ret
save_ok = True
elif args.send:
# --send без --output и --commit: сохраняем по шаблону и отправляем
@@ -533,7 +528,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
return 1
ret = _save_and_maybe_send(
ret, report_sent = _save_and_maybe_send(
rows,
output_arg,
author=Config.get_author(args.author),
@@ -544,6 +539,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
if ret != 0:
return ret
save_ok = True
else:
if args.compact:
@@ -562,24 +558,17 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1
if args.commit:
precision = Config.get_period_precision()
dynamic = Config._app.period_dynamic if Config._app else False
if precision == "datetime":
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
# метки aware cutoff для дедупликации.
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
from_str = now
to_str = now
else:
from_str = from_date
to_str = to_date
# Окно двигается ровно один раз за запуск: либо по явному --commit
# (операция завершилась кодом 0), либо по фактически отправленному письму --send.
should_advance = (args.commit and save_ok) or (args.send and report_sent)
if should_advance:
# Начало следующего окна совпадает с концом текущего: граничный день
# перечитывается. Дубли записей дня коммита осознанны, зато поздние
# записи, добавленные в тот же день после отправки, не теряются.
next_from = to_date
try:
save_period_to_config(
args.config_path, from_str, to_str, precision, dynamic
)
save_period_to_config(args.config_path, from_str=next_from)
except Exception as e:
print(
f"❌ Не удалось сохранить период в конфиг: {e}",
@@ -587,7 +576,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
return 1
print(
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
f"📌 Period committed [{next_from}] → {args.config_path}",
file=sys.stderr,
)

View File

@@ -1,5 +1,4 @@
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union
import requests
@@ -163,33 +162,6 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str:
return str(activity)
def _parse_datetime(value: Any) -> Optional[datetime]:
"""Parse a datetime from Redmine API response.
Accepts datetime objects, ISO strings (with or without timezone),
or None. Returns a timezone-aware datetime or None.
Naive datetimes are treated as UTC (#58).
"""
if value is None:
return None
if isinstance(value, datetime):
return _ensure_aware_utc(value)
if isinstance(value, str):
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return _ensure_aware_utc(dt)
except (ValueError, TypeError):
return None
return None
def _ensure_aware_utc(dt: datetime) -> datetime:
"""Возвращает aware datetime; naive трактуется как UTC (#58)."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = []
@@ -278,18 +250,12 @@ def fetch_issues_with_spent_time(
to_date: str,
user_id: Optional[Union[int, str]] = None,
by_activity: bool = False,
dedup_before: Optional[datetime] = None,
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]:
"""
Fetch unique issues linked to time entries of the given user in date range,
along with total spent hours per issue.
If user_id is None, uses current user.
If by_activity is True, returns per-activity breakdown as third tuple element.
If dedup_before is set, entries with both fields set are kept only when
created_on AND updated_on are both >= dedup_before; an entry is excluded
if at least one of the fields is before the cutoff (dedup_before).
Entries with both fields missing (None) are kept;
if only one field is set, that field alone decides (>= cutoff keeps the entry).
Returns list of (issue, total_hours, activities) tuples.
Raises RedmineAPIError on API/auth/network failures.
"""
@@ -304,7 +270,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
user_id=target_user_id,
from_date=from_date,
to_date=to_date,
)
)
except RedmineAPIError:
@@ -312,38 +280,6 @@ def fetch_issues_with_spent_time(
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте.
# Если оба поля заданы, запись сохраняется, только если created_on И updated_on
# оба >= dedup_before; если хотя бы одно из полей < dedup_before,
# запись исключается.
# Если оба поля None — запись сохраняется; если задано только одно поле,
# решает оно (>= dedup_before → запись сохраняется).
if dedup_before is not None:
# Нормализуем cutoff к aware UTC (#58): naive cutoff трактуем как UTC,
# чтобы сравнение с нормализованными created_on/updated_on было корректным.
dedup_before = _ensure_aware_utc(dedup_before)
filtered: list = []
try:
for entry in time_entries:
created = _parse_datetime(getattr(entry, "created_on", None))
updated = _parse_datetime(getattr(entry, "updated_on", None))
if created is None and updated is None:
filtered.append(entry)
elif created is not None and updated is not None:
if created >= dedup_before and updated >= dedup_before:
filtered.append(entry)
elif created is not None and created >= dedup_before:
filtered.append(entry)
elif updated is not None and updated >= dedup_before:
filtered.append(entry)
except TypeError as exc:
raise RedmineAPIError(
f"Failed to compare time entry dates with deduplication cutoff: {exc}",
original=exc,
) from exc
time_entries = filtered
# Агрегируем часы по issue.id (и активности, если требуется)
spent_time: Dict[int, float] = {}
spent_by_activity: Dict[int, Dict[str, float]] = {}

View File

@@ -2,7 +2,7 @@ import logging
import os
import sys
from dataclasses import dataclass, field
from datetime import date, timedelta
from datetime import date
from pathlib import Path
from typing import Dict, Union
@@ -40,24 +40,13 @@ class EmailConfig:
html: bool = False
@dataclass
class PeriodLastUsed:
from_: str = ""
to: str = ""
@dataclass
class AppConfig:
redmine_url: str = ""
redmine_api_key: str = ""
redmine_author: str = ""
redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY
period_precision: str = "date"
period_default_from: str = ""
period_default_to: str = ""
period_dynamic: bool = False
period_last_used_from: str = ""
period_last_used_to: str = ""
period_from: str = ""
output_dir: str = ""
output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx"
@@ -100,12 +89,7 @@ class AppConfig:
redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"),
redmine_author=cls._resolve_str(raw, "redmine", "author"),
redmine_verify=cls._resolve_verify(raw),
period_precision=cls._resolve_str(raw, "period", "precision") or "date",
period_default_from=cls._resolve_str(raw, "period", "default_from"),
period_default_to=cls._resolve_str(raw, "period", "default_to"),
period_dynamic=cls._resolve_bool(raw, "period", "dynamic"),
period_last_used_from=cls._resolve_str(raw, "period", "last_used", "from"),
period_last_used_to=cls._resolve_str(raw, "period", "last_used", "to"),
period_from=cls._resolve_str(raw, "period", "from"),
output_dir=cls._resolve_str(raw, "output", "dir"),
output_filename=cls._resolve_str(raw, "output", "filename")
or "{author}_{from}_{to}.{ext}",
@@ -118,17 +102,6 @@ class AppConfig:
email=cls._resolve_email(raw),
)
@classmethod
def from_env(cls) -> "AppConfig":
"""Загружает настройки из переменных окружения."""
return cls(
redmine_url=os.getenv("REDMINE_URL", "").strip().rstrip("/"),
redmine_api_key=os.getenv("REDMINE_API_KEY", "").strip(),
redmine_author=os.getenv("REDMINE_AUTHOR", "").strip(),
period_default_from=os.getenv("DEFAULT_FROM_DATE", "").strip(),
period_default_to=os.getenv("DEFAULT_TO_DATE", "").strip(),
)
@staticmethod
def _resolve_str(raw: dict, section: str, *keys: str) -> str:
value = raw.get(section)
@@ -365,24 +338,6 @@ class Config:
return cls._app.redmine_author
return ""
@classmethod
def get_period_precision(cls) -> str:
if cls._app:
return cls._app.period_precision or "date"
return "date"
@classmethod
def get_last_used_from(cls) -> str:
if cls._app:
return cls._app.period_last_used_from
return ""
@classmethod
def get_last_used_to(cls) -> str:
if cls._app:
return cls._app.period_last_used_to
return ""
@classmethod
def get_output_dir(cls) -> str:
if cls._app:
@@ -432,33 +387,35 @@ class Config:
@classmethod
def get_default_date_range(cls) -> str:
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
"""Окно отчёта по умолчанию: from..сегодня.
Приоритет начала: env FROM_DATE -> YAML period.from -> 1-е число
текущего месяца. Некорректная дата не роняет CLI -- логируется
warning и берётся текущий месяц.
"""
today = date.today()
today_str = today.isoformat()
raw_from = os.getenv("FROM_DATE", "").strip()
if not raw_from and cls._app:
raw_from = cls._app.period_from
if from_env:
return f"{from_env}--{to_env or today_str}"
if (
cls._app
and cls._app.period_dynamic
and cls._app.period_last_used_from
and cls._app.period_last_used_to
):
nf, nt = compute_next_period(
cls._app.period_last_used_from,
cls._app.period_last_used_to,
cls._app.period_precision or "date",
if raw_from:
try:
start = date.fromisoformat(raw_from)
except (ValueError, TypeError):
logger.warning(
"Invalid FROM_DATE/period.from %r -- expected ISO date "
"(YYYY-MM-DD); using current month",
raw_from,
)
return f"{nf}--{nt}"
if cls._app and cls._app.period_default_from:
default_to = cls._app.period_default_to or today_str
return f"{cls._app.period_default_from}--{default_to}"
start = today.replace(day=1)
return f"{start.isoformat()}--{today_str}"
else:
start = today.replace(day=1)
# Будущая start инвертирует диапазон и ломает фильтр Redmine,
# поэтому сжимаем окно до сегодняшнего дня.
if start > today:
start = today
return f"{start.isoformat()}--{today.isoformat()}"
@classmethod
def validate(cls) -> None:
@@ -483,52 +440,3 @@ class Config:
raise ValueError(
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
)
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.
- For an arbitrary range → same duration, starting the day after last_to.
- For datetime precision → same duration, starting 1 second after last_to.
"""
from datetime import datetime as dt_mod
if precision == "datetime":
fmt = "%Y-%m-%dT%H:%M:%S"
from_dt = dt_mod.fromisoformat(last_from.replace("Z", "+00:00"))
to_dt = dt_mod.fromisoformat(last_to.replace("Z", "+00:00"))
duration = to_dt - from_dt
next_from_dt = to_dt + timedelta(seconds=1)
next_to_dt = next_from_dt + duration
return next_from_dt.strftime(fmt), next_to_dt.strftime(fmt)
from_parts = last_from.split("-")
to_parts = last_to.split("-")
from_d = date(int(from_parts[0]), int(from_parts[1]), int(from_parts[2]))
to_d = date(int(to_parts[0]), int(to_parts[1]), int(to_parts[2]))
first_of_month = from_d.replace(day=1)
if from_d == first_of_month:
if to_d.month == 12:
last_of_month = date(to_d.year, 12, 31)
else:
next_first = date(to_d.year, to_d.month + 1, 1)
last_of_month = next_first - timedelta(days=1)
if to_d == last_of_month:
if to_d.month == 12:
nstart = date(to_d.year + 1, 1, 1)
else:
nstart = date(to_d.year, to_d.month + 1, 1)
if nstart.month == 12:
nend = date(nstart.year, 12, 31)
else:
nend = date(nstart.year, nstart.month + 1, 1) - timedelta(days=1)
return nstart.isoformat(), nend.isoformat()
duration = to_d - from_d
next_from = to_d + timedelta(days=1)
next_to = next_from + duration
return next_from.isoformat(), next_to.isoformat()

View File

@@ -153,19 +153,11 @@ def resolve_output_path(
return arg
def save_period_to_config(
config_path: str,
from_str: str,
to_str: str,
precision: str,
dynamic: bool,
) -> None:
"""Save committed period to YAML config file.
def save_period_to_config(config_path: str, from_str: str) -> None:
"""Save committed period start to YAML config file.
Writes period.last_used.from / period.last_used.to.
When dynamic=False, also overwrites period.default_from / period.default_to.
Preserves all other sections unchanged. Creates config file if missing.
Writes period.from only: all other sections and keys stay untouched.
Creates the config file (and its parent directory) if missing.
"""
import yaml
@@ -176,11 +168,7 @@ def save_period_to_config(
raw = yaml.safe_load(fh) or {}
period = raw.setdefault("period", {})
period["last_used"] = {"from": from_str, "to": to_str}
if not dynamic:
period["default_from"] = from_str.split("T")[0] if "T" in from_str else from_str
period["default_to"] = to_str.split("T")[0] if "T" in to_str else to_str
period["from"] = from_str
ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh:

View File

@@ -30,10 +30,14 @@ def _reset_config_overrides():
[
("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")),
(" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")),
# datetime-границы флорятся до даты -- окно состоит из целых дней.
(
"2026-06-01T00:00:00--2026-06-30T23:59:59",
("2026-06-01T00:00:00", "2026-06-30T23:59:59"),
("2026-06-01", "2026-06-30"),
),
# Смешение форматов между границами допустимо.
("2026-06-01--2026-06-30T23:59:59", ("2026-06-01", "2026-06-30")),
("2026-06-01T09:30:00--2026-06-30", ("2026-06-01", "2026-06-30")),
],
)
def test_parse_date_range_valid(date_arg, expected):
@@ -48,7 +52,8 @@ def test_parse_date_range_valid(date_arg, expected):
"2026-02-30--2026-03-01",
"2026-02-01--2026-01-31",
"2026-06-01T25:00:00--2026-06-30T23:59:59",
"2026-06-01--2026-06-30T23:59:59",
"2026-06-01 00:00:00--2026-06-30",
"2026-06-01T00:00:00+03:00--2026-06-30T23:59:59",
"2026-06-30T23:59:59--2026-06-01T00:00:00",
],
)
@@ -438,8 +443,7 @@ class TestInitConfig:
"REDMINE_URL=https://redmine.example.com\n"
"REDMINE_API_KEY=secret-from-dotenv\n"
"REDMINE_AUTHOR=ivan.ivanov\n"
"DEFAULT_FROM_DATE=2026-01-01\n"
"DEFAULT_TO_DATE=2026-01-31\n"
"FROM_DATE=2026-01-01\n"
"SMTP_PASSWORD=smtp-secret\n"
)
monkeypatch.chdir(tmp_path)
@@ -448,8 +452,7 @@ class TestInitConfig:
"REDMINE_URL",
"REDMINE_API_KEY",
"REDMINE_AUTHOR",
"DEFAULT_FROM_DATE",
"DEFAULT_TO_DATE",
"FROM_DATE",
"SMTP_PASSWORD",
):
monkeypatch.delenv(var, raising=False)
@@ -464,8 +467,7 @@ class TestInitConfig:
assert data["redmine"]["url"] == "https://redmine.example.com"
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
assert data["redmine"]["author"] == "ivan.ivanov"
assert data["period"]["default_from"] == "2026-01-01"
assert data["period"]["default_to"] == "2026-01-31"
assert data["period"]["from"] == "2026-01-01"
assert data["email"]["smtp"]["password"] == "${SMTP_PASSWORD}"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@@ -678,16 +680,14 @@ class TestCommitFlag:
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_saves_period_to_config(self, mock_save, mock_fetch, tmp_path):
"""--commit сохраняет период в YAML-конфиг."""
"""--commit пишет в period.from конец окна отчёта (день коммита)."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
@@ -705,34 +705,29 @@ class TestCommitFlag:
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_with_precision_datetime_passes_datetime_strings(
def test_commit_period_from_equals_window_end(
self, mock_save, mock_fetch, tmp_path
):
"""При precision=datetime --commit сохраняет timestamps."""
"""Конец окна 2026-09-30 -- period.from ровно 2026-09-30, не 2026-10-01."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
)
config_path.write_text(yaml.dump({"period": {"from": "2026-09-01"}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_get.return_value = mock.MagicMock()
code = main(
[
"--date",
"2026-06-30--2026-06-30",
"2026-09-01--2026-09-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
@@ -742,38 +737,29 @@ class TestCommitFlag:
)
assert code == 0
call_args = mock_save.call_args
assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2]
assert "T" in saved_from
assert "T" in saved_to
mock_save.assert_called_once_with(str(config_path), from_str="2026-09-30")
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_with_precision_datetime_saves_aware_utc(
def test_commit_period_from_equals_window_end_year_boundary(
self, mock_save, mock_fetch, tmp_path
):
"""При precision=datetime --commit сохраняет last_used как aware UTC (#58)."""
from datetime import datetime, timezone
"""Окно до 2026-12-31 -- from_str равен 2026-12-31: timedelta-математики нет."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
)
config_path.write_text(yaml.dump({"period": {"from": "2026-12-01"}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_get.return_value = mock.MagicMock()
code = main(
[
"--date",
"2026-06-30--2026-06-30",
"2026-12-01--2026-12-31",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
@@ -783,70 +769,7 @@ class TestCommitFlag:
)
assert code == 0
call_args = mock_save.call_args
assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2]
for saved in (saved_from, saved_to):
parsed = datetime.fromisoformat(saved)
assert parsed.tzinfo is not None, f"{saved} must be timezone-aware"
assert parsed.utcoffset() == timezone.utc.utcoffset(None)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
"""Без записей --commit не пишет конфиг."""
mock_fetch.return_value = None
config_path = tmp_path / "config.yml"
config_path.write_text("period:\n precision: date\n dynamic: true\n")
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
@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
):
"""При dynamic=false --commit перезаписывает default_from/to."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": False}})
)
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
mock_save.assert_called_once_with(
str(config_path), "2026-06-01", "2026-06-30", "date", False
)
mock_save.assert_called_once_with(str(config_path), from_str="2026-12-31")
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@@ -864,7 +787,7 @@ class TestCommitFlag:
config_path.write_text(
yaml.dump(
{
"period": {"precision": "date", "dynamic": True},
"period": {"from": "2026-06-01"},
"output": {
"dir": str(tmp_path / "reports"),
"filename": "report_{date}.{ext}",
@@ -902,9 +825,7 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
@@ -928,16 +849,12 @@ 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_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path):
"""Пустой список задач — --commit не сохраняет конфиг."""
import yaml
mock_fetch.return_value = []
def test_commit_no_entries_does_not_save(self, mock_save, mock_fetch, tmp_path):
"""Без записей --commit не пишет конфиг."""
mock_fetch.return_value = None
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
config_path.write_text("period:\n from: '2026-01-01'\n")
code = main(
[
@@ -951,6 +868,64 @@ class TestCommitFlag:
assert code == 0
mock_save.assert_not_called()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path):
"""Пустой список задач — --commit не сохраняет конфиг."""
import yaml
mock_fetch.return_value = []
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"from": "2026-01-01"}}))
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_not_called()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_save_error_does_not_advance_period(
self, mock_save, mock_fetch, tmp_path
):
"""Сбой записи отчёта не двигает окно: --commit фиксирует только успешно сохранённый отчёт."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_formatter.save.side_effect = OSError("disk full")
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 == 1
mock_save.assert_not_called()
# ---------------------------------------------------------------------------
# #45: --send tests
@@ -964,8 +939,12 @@ class TestSendFlag:
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path):
@mock.patch("builtins.input")
def test_send_triggers_mailer(
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""--send с --output вызывает send_report после сохранения."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
@@ -1003,10 +982,12 @@ class TestSendFlag:
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_without_output_saves_to_default_path(
self, mock_get, mock_send, mock_fetch, tmp_path
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
@@ -1071,10 +1052,12 @@ class TestSendFlag:
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_smtp_error_reported_to_stderr(
self, mock_get, mock_send, mock_fetch, tmp_path, capsys
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys
):
"""Ошибка SMTP выводится в stderr, exit code 1."""
mock_input.return_value = "y"
from redmine_reporter.client import RedmineAPIError
issue = _MockIssue()
@@ -1118,10 +1101,12 @@ class TestSendFlag:
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("redmine_reporter.cli.save_period_to_config")
@mock.patch("builtins.input")
def test_send_with_commit_works_together(
self, mock_save, mock_get, mock_send, mock_fetch, tmp_path
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
):
"""--send и --commit работают вместе без конфликтов."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
@@ -1139,8 +1124,7 @@ class TestSendFlag:
" to:\n"
" - boss@example.com\n"
"period:\n"
" precision: date\n"
" dynamic: true\n"
" from: '2026-06-01'\n"
)
code = main(
@@ -1157,7 +1141,7 @@ class TestSendFlag:
)
assert code == 0
mock_send.assert_called_once()
mock_save.assert_called_once()
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@@ -1167,6 +1151,192 @@ class TestSendFlag:
code = main(["--date", "2026-06-01--2026-06-30", "--send"])
assert code == 0
@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")
@mock.patch("builtins.input")
def test_send_success_advances_period(
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
):
"""Успешная отправка сдвигает period.from на конец окна отчёта."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_get.return_value = mock.MagicMock()
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"
)
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--output",
str(tmp_path / "report.xlsx"),
"--send",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
@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")
@mock.patch("builtins.input")
def test_send_declined_does_not_advance_period(
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
):
"""Отказ от подтверждения не сдвигает окно."""
mock_input.return_value = "n"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_get.return_value = mock.MagicMock()
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"
)
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--output",
str(tmp_path / "report.xlsx"),
"--send",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_send.assert_not_called()
mock_save.assert_not_called()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("redmine_reporter.cli.save_period_to_config")
@mock.patch("builtins.input")
def test_send_smtp_error_does_not_advance_period(
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
):
"""Ошибка SMTP не сдвигает окно."""
from redmine_reporter.client import RedmineAPIError
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_get.return_value = mock.MagicMock()
mock_send.side_effect = RedmineAPIError("SMTP connection failed")
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"
)
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--output",
str(tmp_path / "report.xlsx"),
"--send",
"--config-path",
str(config_path),
]
)
assert code == 1
mock_save.assert_not_called()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("redmine_reporter.cli.save_period_to_config")
@mock.patch("builtins.input")
def test_send_commit_declined_send_still_advances(
self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path
):
"""--send --commit: отказ от отправки не мешает явному --commit сдвинуть окно."""
mock_input.return_value = "n"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_get.return_value = mock.MagicMock()
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"
" from: '2026-06-01'\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_not_called()
mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
@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_send_commit_no_entries_does_not_save(self, mock_save, mock_fetch):
"""--send --commit без записей не трогает конфиг."""
mock_fetch.return_value = None
code = main(["--date", "2026-06-01--2026-06-30", "--send", "--commit"])
assert code == 0
mock_save.assert_not_called()
class TestSendHtmlBody:
"""Tests for email.html body generation."""
@@ -1175,10 +1345,12 @@ class TestSendHtmlBody:
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_passes_rows_to_send_report(
self, mock_get, mock_send, mock_fetch, tmp_path
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""--send передаёт rows в send_report для генерации HTML."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
@@ -1220,10 +1392,12 @@ class TestSendHtmlBody:
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_html_false_does_not_require_html_part(
self, mock_get, mock_send, mock_fetch, tmp_path
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""При email.html: false письмо отправляется без HTML-части."""
mock_input.return_value = "y"
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
@@ -1317,7 +1491,7 @@ class TestReportNoTimeIntegration:
{
"redmine": {"url": "https://x.com", "api_key": "token"},
"report": {"no_time": True},
"period": {"precision": "date", "dynamic": True},
"period": {"from": "2026-06-01"},
}
)
)
@@ -1357,7 +1531,7 @@ class TestReportNoTimeIntegration:
{
"redmine": {"url": "https://x.com", "api_key": "token"},
"report": {"no_time": False},
"period": {"precision": "date", "dynamic": True},
"period": {"from": "2026-06-01"},
}
)
)
@@ -1397,7 +1571,7 @@ class TestReportNoTimeIntegration:
{
"redmine": {"url": "https://x.com", "api_key": "token"},
"report": {"no_time": False},
"period": {"precision": "date", "dynamic": True},
"period": {"from": "2026-06-01"},
}
)
)
@@ -1462,8 +1636,12 @@ class TestReportNoTimeIntegration:
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
def test_send_with_report_no_time_true(self, mock_send, mock_fetch, tmp_path):
@mock.patch("builtins.input")
def test_send_with_report_no_time_true(
self, mock_input, mock_send, mock_fetch, tmp_path
):
"""--send с report.no_time: true передаёт no_time=True в форматтер."""
mock_input.return_value = "y"
import yaml
issue = _MockIssue()
@@ -1509,43 +1687,6 @@ class TestReportNoTimeIntegration:
assert kwargs.get("no_time") is True
# ---------------------------------------------------------------------------
# #59: dynamic + precision=datetime без --date
# ---------------------------------------------------------------------------
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
"""dynamic+datetime без --date: datetime-диапазон из last_used принимается.
Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон
с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used,
отчёт строится без ошибки.
"""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
"period:\n"
" precision: datetime\n"
" dynamic: true\n"
" last_used:\n"
" from: '2026-06-01T00:00:00'\n"
" to: '2026-06-30T23:59:59'\n"
)
code = main(["--config-path", str(config_path)])
assert code == 0
args, _ = mock_fetch.call_args
from_date, to_date = args[0], args[1]
# Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59
assert from_date == "2026-07-01T00:00:00"
assert to_date == "2026-07-30T23:59:59"
# ---------------------------------------------------------------------------
# #55: sanitize Redmine exception output in CLI
# ---------------------------------------------------------------------------
@@ -1660,3 +1801,199 @@ class TestSanitizeErrorOutput:
assert "raw details" not in captured.err
assert "raw details" not in caplog.text
assert "safe message" in captured.err
# ---------------------------------------------------------------------------
# Email confirmation prompt before --send
# ---------------------------------------------------------------------------
class TestSendConfirmation:
"""Tests for email confirmation prompt before sending."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_confirmation_yes_calls_send_report(
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""Подтверждение 'y' — send_report вызывается."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_input.return_value = "y"
config_path = tmp_path / "config.yml"
config_path.write_text(
"email:\n"
" smtp:\n"
" host: smtp.example.com\n"
" port: 587\n"
" user: bot\n"
" password: secret\n"
" from: bot@example.com\n"
" to:\n"
" - boss@example.com\n"
" cc:\n"
" - cc@example.com\n"
" bcc:\n"
" - bcc@example.com\n"
)
output = str(tmp_path / "report.xlsx")
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--output",
output,
"--send",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_send.assert_called_once()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_confirmation_no_skips_send(
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""Ответ 'n' — send_report не вызывается."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_input.return_value = "n"
config_path = tmp_path / "config.yml"
config_path.write_text(
"email:\n"
" smtp:\n"
" host: smtp.example.com\n"
" port: 587\n"
" user: bot\n"
" password: secret\n"
" from: bot@example.com\n"
" to:\n"
" - boss@example.com\n"
)
output = str(tmp_path / "report.xlsx")
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--output",
output,
"--send",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_send.assert_not_called()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_confirmation_default_skips_send(
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path
):
"""Пустой ввод (Enter) — send_report не вызывается."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_input.return_value = ""
config_path = tmp_path / "config.yml"
config_path.write_text(
"email:\n"
" smtp:\n"
" host: smtp.example.com\n"
" port: 587\n"
" user: bot\n"
" password: secret\n"
" from: bot@example.com\n"
" to:\n"
" - boss@example.com\n"
)
output = str(tmp_path / "report.xlsx")
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--output",
output,
"--send",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_send.assert_not_called()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
@mock.patch("builtins.input")
def test_send_confirmation_shows_recipients(
self, mock_input, mock_get, mock_send, mock_fetch, tmp_path, capsys
):
"""Подтверждение показывает from, to, cc, bcc, file."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_input.return_value = "y"
config_path = tmp_path / "config.yml"
config_path.write_text(
"email:\n"
" smtp:\n"
" host: smtp.example.com\n"
" port: 587\n"
" user: bot\n"
" password: secret\n"
" from: sender@example.com\n"
" to:\n"
" - boss@example.com\n"
" cc:\n"
" - cc@example.com\n"
" bcc:\n"
" - bcc@example.com\n"
)
output = str(tmp_path / "report.xlsx")
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--output",
output,
"--send",
"--config-path",
str(config_path),
]
)
assert code == 0
captured = capsys.readouterr()
assert "sender@example.com" in captured.out
assert "boss@example.com" in captured.out
assert "cc@example.com" in captured.out
assert "bcc@example.com" in captured.out
assert output in captured.out
mock_input.assert_called_once_with("Отправить? [y/N]: ")

View File

@@ -605,256 +605,6 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
assert len(result) == 250
# -- #47: Дедупликация time entries по datetime --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
"""Записи с created_on/updated_on раньше dedup_before исключаются."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
e1.updated_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
e2 = mock.MagicMock()
e2.issue.id = 2
e2.hours = 1.0
e2.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e2.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
mock_redmine.time_entry.filter.return_value = [e1, e2]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
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
)
assert result is not None
assert len(result) == 1
assert result[0][0].id == 2
@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,
):
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
e1.updated_on = datetime(2026, 7, 1, 15, 0, 0, tzinfo=timezone.utc)
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
)
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
"""Записи с created_on/updated_on >= dedup_before сохраняются."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1.updated_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
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
)
assert result is not None
assert len(result) == 1
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_entries_without_created_on_are_kept(mock_redmine_class):
"""Записи без created_on/updated_on не фильтруются (сохраняются)."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = None
e1.updated_on = None
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
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
)
assert result is not None
assert len(result) == 1
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_handles_string_created_on(mock_redmine_class):
"""created_on может быть строкой ISO — парсим корректно."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = "2026-07-01T10:00:00Z"
e1.updated_on = "2026-07-01T10:00:00Z"
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
)
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_no_dedup_when_cutoff_is_none(mock_redmine_class):
"""Без dedup_before фильтрация не применяется."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = None
e1.updated_on = None
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
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=None)
assert result is not None
assert len(result) == 1
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
"""Смешанные записи: старые исключаются, новые сохраняются, аки идут."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e_old = mock.MagicMock()
e_old.issue.id = 10
e_old.hours = 1.0
e_old.created_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
e_old.updated_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
e_new = mock.MagicMock()
e_new.issue.id = 20
e_new.hours = 3.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e_akiy = mock.MagicMock()
e_akiy.issue.id = 30
e_akiy.hours = 5.0
e_akiy.created_on = None
e_akiy.updated_on = None
mock_redmine.time_entry.filter.return_value = [e_old, e_new, e_akiy]
mock_issue_new = mock.MagicMock()
mock_issue_new.id = 20
mock_issue_new.project = "P"
mock_issue_new.subject = "T"
mock_issue_new.status = "New"
mock_issue_akiy = mock.MagicMock()
mock_issue_akiy.id = 30
mock_issue_akiy.project = "P"
mock_issue_akiy.subject = "T2"
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
)
assert result is not None
assert len(result) == 2
ids = {r[0].id for r in result}
assert ids == {20, 30}
# -- Пагинация time entries (>100 записей) --
@@ -904,149 +654,6 @@ def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
assert hours_by_id[2] == 100.0
# -- #58: дедупликация при precision=datetime с naive created_on --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class):
"""python-redmine отдаёт naive created_on; cutoff aware (UTC) — фильтрация не падает.
Ожидаемое поведение после фикса #58: naive datetime трактуется как UTC,
старые записи отсекаются, новые сохраняются.
"""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e_old = mock.MagicMock()
e_old.issue.id = 1
e_old.hours = 2.0
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0) # naive, как из python-redmine
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
e_new = mock.MagicMock()
e_new.issue.id = 2
e_new.hours = 1.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0) # naive
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_issue2.project = "P"
mock_issue2.subject = "T"
mock_issue2.status = "New"
# API возвращает обе задачи: issue 1 попадёт в результат, только если
# её time entry не отсечён дедупликацией. Корректный фикс #58 отсекает
# e_old (created_on/updated_on < cutoff) -> [2]; неправильный фикс
# "погасить TypeError и оставить всё" даст [1, 2] и тест упадёт.
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
)
assert result is not None
assert [r[0].id for r in result] == [2]
# -- #58: нормализация datetime к aware UTC --
class TestParseDatetimeNormalization:
"""_parse_datetime всегда возвращает aware datetime; naive трактуется как UTC."""
def test_naive_datetime_treated_as_utc(self):
from datetime import datetime, timezone
from redmine_reporter.client import _parse_datetime
result = _parse_datetime(datetime(2026, 7, 1, 10, 0, 0))
assert result == datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
def test_aware_datetime_kept(self):
from datetime import datetime, timedelta, timezone
from redmine_reporter.client import _parse_datetime
aware = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone(timedelta(hours=3)))
assert _parse_datetime(aware) is aware
def test_naive_iso_string_treated_as_utc(self):
from datetime import timezone
from redmine_reporter.client import _parse_datetime
result = _parse_datetime("2026-07-01T10:00:00")
assert result is not None
assert result.tzinfo == timezone.utc
def test_aware_iso_string_keeps_offset(self):
from datetime import timedelta
from redmine_reporter.client import _parse_datetime
result = _parse_datetime("2026-07-01T10:00:00+03:00")
assert result is not None
assert result.utcoffset() == timedelta(hours=3)
def test_none_and_garbage_return_none(self):
from redmine_reporter.client import _parse_datetime
assert _parse_datetime(None) is None
assert _parse_datetime("not a date") is None
assert _parse_datetime(123) is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_accepts_naive_cutoff(mock_redmine_class):
"""Naive dedup_before трактуется как UTC — сравнение не падает (#58)."""
from datetime import datetime
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
naive_cutoff = datetime(2026, 7, 1, 12, 0, 0) # naive == UTC
e_old = mock.MagicMock()
e_old.issue.id = 1
e_old.hours = 2.0
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0)
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
e_new = mock.MagicMock()
e_new.issue.id = 2
e_new.hours = 1.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0)
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=naive_cutoff
)
assert result is not None
assert [r[0].id for r in result] == [2]
# -- #61: предупреждения о молчаливой потере данных --
@@ -1153,3 +760,48 @@ def test_load_time_entry_activities_returns_empty_on_failure(capsys):
captured = capsys.readouterr()
assert "activit" in captured.err.lower()
# -- Границы периода передаются в API как есть --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_passes_date_boundaries_to_api_unchanged(mock_redmine_class):
"""Флоринг границ выполняет cli-парсер, клиент отдаёт даты в filter как есть."""
mock_redmine = mock_redmine_class.return_value
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-09-30", "2026-10-31", user_id=458)
mock_redmine.time_entry.filter.assert_called_once_with(
user_id=458, from_date="2026-09-30", to_date="2026-10-31"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_works_without_dedup_argument(mock_redmine_class):
"""dedup-слой удалён: без dedup-аргумента все записи периода агрегируются."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
entry = mock.MagicMock()
entry.issue.id = 1
entry.hours = 2.0
entry.created_on = None
entry.updated_on = None
mock_redmine.time_entry.filter.return_value = [entry]
mock_issue = mock.MagicMock()
mock_issue.id = 1
mock_issue.project = "P"
mock_issue.subject = "T"
mock_issue.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue]
result = fetch_issues_with_spent_time("2026-09-30", "2026-09-30", user_id=123)
assert result is not None
assert [r[0].id for r in result] == [1]
assert result[0][1] == 2.0

View File

@@ -1,3 +1,4 @@
import contextlib
import os
import tempfile
from pathlib import Path
@@ -8,6 +9,18 @@ import pytest
from redmine_reporter.config import AppConfig, Config, EmailConfig
@contextlib.contextmanager
def mock_today(fake_today):
"""Подменяет date.today() в config, сохраняя реальные date() и fromisoformat()."""
from datetime import date as real_date
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = fake_today
mock_date.side_effect = lambda *a, **kw: real_date(*a, **kw)
mock_date.fromisoformat.side_effect = real_date.fromisoformat
yield
@mock.patch.dict(
os.environ,
{
@@ -62,25 +75,9 @@ def test_get_author_fallback():
assert Config.get_author("") == ""
@mock.patch.dict(
os.environ,
{
"DEFAULT_FROM_DATE": "2026-01-01",
"DEFAULT_TO_DATE": "2026-01-31",
},
clear=True,
)
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-01-01"}, clear=True)
def test_get_default_date_range_from_env():
assert Config.get_default_date_range() == "2026-01-01--2026-01-31"
@mock.patch.dict(
os.environ,
{"DEFAULT_FROM_DATE": "2026-01-01"},
clear=True,
)
def test_get_default_date_range_from_env_without_to():
"""Если DEFAULT_TO_DATE не задан, конец периода — сегодня."""
"""FROM_DATE задаёт начало периода, конец всегда -- сегодня."""
from datetime import date
today = date.today().isoformat()
@@ -126,31 +123,98 @@ class TestDefaultPeriodIsCurrentMonth:
assert Config.get_default_date_range() == "2026-05-01--2026-05-01"
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_default_from_falls_back_to_today(self):
"""default_from задан, default_to нет → конец периода = сегодня."""
def test_yaml_period_from_runs_to_today(self):
"""period.from задан -- конец периода всегда сегодня."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n default_from: '2026-04-01'\n")
yaml_path.write_text("period:\n from: '2026-04-01'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 4, 20)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
with mock_today(date(2026, 4, 20)):
assert Config.get_default_date_range() == "2026-04-01--2026-04-20"
@mock.patch.dict(os.environ, {"DEFAULT_FROM_DATE": "2026-02-01"}, clear=True)
def test_env_from_without_to_falls_back_to_today(self):
"""DEFAULT_FROM_DATE задана, DEFAULT_TO_DATE нет → конец = сегодня."""
class TestDefaultDateRangePeriodFrom:
"""Приоритет окна: env FROM_DATE → YAML period.from → текущий месяц."""
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-02-01"}, clear=True)
def test_env_from_runs_to_today(self):
"""FROM_DATE задаёт начало, конец всегда сегодня."""
from datetime import date
Config._app = None
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 2, 10)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
with mock_today(date(2026, 2, 10)):
assert Config.get_default_date_range() == "2026-02-01--2026-02-10"
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True)
def test_env_from_wins_over_yaml_period_from(self):
"""env FROM_DATE имеет приоритет над YAML period.from."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n from: '2026-03-01'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with mock_today(date(2026, 7, 15)):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-12-31"}, clear=True)
def test_env_from_in_future_clamps_to_today(self):
"""from позже сегодня -- окно 'сегодня--сегодня', без инверсии."""
from datetime import date
Config._app = None
with mock_today(date(2026, 9, 15)):
assert Config.get_default_date_range() == "2026-09-15--2026-09-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_period_from_in_future_clamps_to_today(self):
"""YAML period.from позже сегодня -- окно 'сегодня--сегодня'."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n from: '2026-12-31'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with mock_today(date(2026, 9, 15)):
assert Config.get_default_date_range() == "2026-09-15--2026-09-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_malformed_yaml_period_from_warns_and_uses_current_month(self, caplog):
"""period.from не дата -- warning в лог, окно = текущий месяц."""
import logging
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n from: 'not-a-date'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with caplog.at_level(logging.WARNING):
with mock_today(date(2026, 9, 15)):
rng = Config.get_default_date_range()
assert rng == "2026-09-01--2026-09-15"
assert "not-a-date" in caplog.text
@mock.patch.dict(os.environ, {"FROM_DATE": "garbage"}, clear=True)
def test_malformed_env_from_warns_and_uses_current_month(self, caplog):
"""FROM_DATE не дата -- warning в лог, окно = текущий месяц."""
import logging
from datetime import date
Config._app = None
with caplog.at_level(logging.WARNING):
with mock_today(date(2026, 9, 15)):
rng = Config.get_default_date_range()
assert rng == "2026-09-01--2026-09-15"
assert "garbage" in caplog.text
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_redmine_verify_default():
@@ -299,13 +363,7 @@ redmine:
verify_ssl: false
period:
precision: datetime
default_from: "2026-06-01"
default_to: "2026-06-30"
dynamic: true
last_used:
from: "2026-06-30T09:00:00"
to: "2026-06-30T12:00:00"
from: "2026-06-01"
output:
dir: ~/reports
@@ -344,12 +402,7 @@ class TestAppConfigFromYaml:
assert cfg.redmine_api_key == "" # ${MISSING_API_KEY_FOR_TEST} not set
assert cfg.redmine_author == "Тестовый Автор"
assert cfg.redmine_verify is False
assert cfg.period_precision == "datetime"
assert cfg.period_default_from == "2026-06-01"
assert cfg.period_default_to == "2026-06-30"
assert cfg.period_dynamic is True
assert cfg.period_last_used_from == "2026-06-30T09:00:00"
assert cfg.period_last_used_to == "2026-06-30T12:00:00"
assert cfg.period_from == "2026-06-01"
assert cfg.output_dir == "~/reports"
assert cfg.output_filename == "{author}_{from}_{to}.{ext}"
assert cfg.output_default_format == "xlsx"
@@ -372,7 +425,7 @@ class TestAppConfigFromYaml:
assert cfg.redmine_url == "https://x.com/"
# defaults for everything else
assert cfg.redmine_author == ""
assert cfg.period_precision == "date"
assert cfg.period_from == ""
assert cfg.output_dir == ""
assert cfg.email.to == []
@@ -385,7 +438,36 @@ class TestAppConfigFromYaml:
assert cfg.redmine_url == ""
assert cfg.redmine_api_key == ""
assert cfg.period_precision == "date"
assert cfg.period_from == ""
def test_period_from_loaded_from_yaml(self):
"""period.from читается из YAML как единственный ключ периода."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n from: '2026-06-01'\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_from == "2026-06-01"
def test_old_period_keys_are_ignored(self):
"""Устаревшие ключи period молча игнорируются -- читается только from."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: datetime\n"
" dynamic: true\n"
" default_from: '2026-01-01'\n"
" default_to: '2026-01-31'\n"
" last_used:\n"
" from: '2026-01-31T09:00:00'\n"
" to: '2026-01-31T12:00:00'\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_from == ""
def test_file_not_found_returns_all_defaults(self):
cfg = AppConfig.from_yaml("/nonexistent/path/config.yml")
@@ -533,14 +615,15 @@ class TestConfigYamlFallback:
@mock.patch.dict(os.environ, {}, clear=True)
def test_date_range_from_yaml(self):
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n"
)
yaml_path.write_text("period:\n from: '2026-03-01'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with mock_today(date(2026, 3, 15)):
assert Config.get_default_date_range() == "2026-03-01--2026-03-15"
@mock.patch.dict(os.environ, {}, clear=True)
@@ -570,205 +653,17 @@ class TestConfigYamlFallback:
@mock.patch.dict(os.environ, {}, clear=True)
def test_default_date_range_env_beats_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
with mock.patch.dict(
os.environ,
{"DEFAULT_FROM_DATE": "2026-07-01", "DEFAULT_TO_DATE": "2026-07-15"},
clear=True,
):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
class TestConfigPeriodPrecision:
"""Tests for period.precision and period.last_used."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_period_precision_defaults_to_date(self):
"""Без YAML-конфига precision == 'date'."""
Config._app = None
assert Config.get_period_precision() == "date"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_period_precision_from_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n precision: datetime\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_period_precision() == "datetime"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_last_used_from_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n last_used:\n from: '2026-06-30T09:00:00'\n to: '2026-06-30T12:00:00'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_last_used_from() == "2026-06-30T09:00:00"
assert Config.get_last_used_to() == "2026-06-30T12:00:00"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_last_used_returns_empty_when_not_set(self):
Config._app = AppConfig()
assert Config.get_last_used_from() == ""
assert Config.get_last_used_to() == ""
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_loads_last_used_field(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n last_used:\n from: '2026-07-01T08:00:00'\n to: '2026-07-01T18:00:00'\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_last_used_from == "2026-07-01T08:00:00"
assert cfg.period_last_used_to == "2026-07-01T18:00:00"
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_without_last_used_has_empty_fields(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n precision: date\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_last_used_from == ""
assert cfg.period_last_used_to == ""
class TestComputeNextPeriod:
"""Tests for compute_next_period()."""
def test_full_month_goes_to_next_full_month(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-01", "2026-06-30", "date")
assert nf == "2026-07-01"
assert nt == "2026-07-31"
def test_december_to_next_year_january(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2025-12-01", "2025-12-31", "date")
assert nf == "2026-01-01"
assert nt == "2026-01-31"
def test_february_2026_non_leap_to_march(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-02-01", "2026-02-28", "date")
assert nf == "2026-03-01"
assert nt == "2026-03-31"
def test_arbitrary_range_same_length_from_to_plus_one(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-15", "2026-06-20", "date")
assert nf == "2026-06-21"
assert nt == "2026-06-26"
def test_single_day_range_moves_one_day(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-15", "2026-06-15", "date")
assert nf == "2026-06-16"
assert nt == "2026-06-16"
def test_cross_month_range(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-25", "2026-07-05", "date")
assert nf == "2026-07-06"
assert nt == "2026-07-16"
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"
)
assert nf == "2026-06-30T12:00:01"
assert nt == "2026-06-30T15:00:01"
class TestDefaultDateRangeWithLastUsed:
"""Tests that get_default_date_range() uses last_used when dynamic=True."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_uses_last_used_when_dynamic_true(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: true\n"
" last_used:\n"
" from: '2026-05-01'\n"
" to: '2026-05-31'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-06-01--2026-06-30"
@mock.patch.dict(os.environ, {}, clear=True)
def test_ignores_last_used_when_dynamic_false(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-03-01'\n"
" default_to: '2026-03-15'\n"
" last_used:\n"
" from: '2026-06-01'\n"
" to: '2026-06-30'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-03-01--2026-03-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_falls_back_when_no_last_used_even_with_dynamic(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: true\n"
" default_from: '2026-04-01'\n"
" default_to: '2026-04-15'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-04-01--2026-04-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_falls_back_to_today_when_default_to_missing(self):
"""Если default_from задан, а default_to нет — конец периода сегодня."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-04-01'\n"
)
yaml_path.write_text("period:\n from: '2026-03-01'\n")
Config._app = AppConfig.from_yaml(yaml_path)
today = date.today().isoformat()
result = Config.get_default_date_range()
assert result == f"2026-04-01--{today}"
with mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True):
with mock_today(date(2026, 7, 15)):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
class TestGetEmailConfig:

View File

@@ -4,11 +4,14 @@ import tempfile
from pathlib import Path
from unittest import mock
import yaml
from redmine_reporter.yaml_config import (
check_file_permissions,
ensure_config_dir,
expand_filename_template,
resolve_env_vars,
save_period_to_config,
)
@@ -211,120 +214,65 @@ class TestResolveOutputPath:
class TestSavePeriodToConfig:
"""Tests for save_period_to_config()."""
def test_creates_last_used_in_empty_config(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
def test_creates_period_from_in_empty_config(self, tmp_path):
config_path = tmp_path / "config.yml"
save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
config_path.write_text("")
save_period_to_config(str(config_path), "2026-06-01")
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
assert "default_from" not in data["period"]
def test_overwrites_existing_last_used(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
assert data["period"]["from"] == "2026-06-01"
def test_overwrites_existing_period_from(self, tmp_path):
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"
)
config_path.write_text("period:\n from: '2026-05-01'\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")
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
def test_dynamic_false_overwrites_defaults(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
config_path.write_text(
"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
)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["default_from"] == "2026-06-01"
assert data["period"]["default_to"] == "2026-06-30"
assert data["period"]["last_used"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
def test_datetime_precision_stores_timestamps(self, tmp_path):
import yaml
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-30T09:00:00",
"2026-06-30T12:00:00",
"datetime",
True,
)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-30T09:00:00"
assert data["period"]["last_used"]["to"] == "2026-06-30T12:00:00"
def test_preserves_existing_sections(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
assert data["period"]["from"] == "2026-06-01"
def test_preserves_other_sections_and_keys(self, tmp_path):
config_path = tmp_path / "config.yml"
config_path.write_text(
"redmine:\n"
" url: https://example.com\n"
" author: Test\n"
"email:\n"
" enabled: true\n"
"output:\n"
" dir: /tmp\n"
)
save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
save_period_to_config(str(config_path), "2026-06-01")
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["redmine"]["url"] == "https://example.com"
assert data["redmine"]["author"] == "Test"
assert data["output"]["dir"] == "/tmp"
assert data["period"]["last_used"]["from"] == "2026-06-01"
assert data["redmine"] == {"url": "https://example.com", "author": "Test"}
assert data["email"] == {"enabled": True}
assert data["output"] == {"dir": "/tmp"}
assert data["period"] == {"from": "2026-06-01"}
def test_sets_permissions_0600(self, tmp_path):
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")
mode = config_path.stat().st_mode & 0o777
assert mode == 0o600
def test_creates_config_file_and_dir_when_missing(self, tmp_path):
config_path = tmp_path / "nested" / "config.yml"
assert not config_path.exists()
save_period_to_config(str(config_path), "2026-06-01")
assert config_path.exists()
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["from"] == "2026-06-01"