2 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
13 changed files with 728 additions and 1367 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.12.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.12.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()
return start.date().isoformat(), end.date().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,7 @@ 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_}")
@@ -267,7 +245,7 @@ def _save_and_maybe_send(
response = input("Отправить? [y/N]: ").strip().lower()
if response not in ("y", "yes"):
print("⏭️ Отправка отменена.")
return 0
return 0, False
try:
send_report(
@@ -280,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:
@@ -363,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)
@@ -442,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)
@@ -485,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(
@@ -515,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),
@@ -526,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: сохраняем по шаблону и отправляем
@@ -546,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),
@@ -557,6 +539,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
if ret != 0:
return ret
save_ok = True
else:
if args.compact:
@@ -575,23 +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":
# from -- фактическое начало окна отчёта; to -- дедуп-маркер (naive UTC).
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
from_str = from_date
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}",
@@ -599,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, datetime, timedelta, timezone
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,50 +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 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,
)
start = today.replace(day=1)
else:
start = today.replace(day=1)
if (
cls._app
and cls._app.period_dynamic
and (cls._app.period_precision or "date") == "datetime"
and cls._app.period_last_used_to
):
last_to = datetime.fromisoformat(
cls._app.period_last_used_to.replace("Z", "+00:00")
)
if last_to.tzinfo is None:
last_to = last_to.replace(tzinfo=timezone.utc)
else:
last_to = last_to.astimezone(timezone.utc)
now_utc = datetime.now(timezone.utc)
fmt = "%Y-%m-%dT%H:%M:%S"
return f"{last_to.strftime(fmt)}--{now_utc.strftime(fmt)}"
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",
)
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}"
# Будущая start инвертирует диапазон и ломает фильтр Redmine,
# поэтому сжимаем окно до сегодняшнего дня.
if start > today:
start = today
return f"{start.isoformat()}--{today.isoformat()}"
@classmethod
def validate(cls) -> None:
@@ -500,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,36 +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 сохраняет from окна и to в naive UTC."""
from datetime import datetime
"""Конец окна 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-09-10T09:00:00--2026-09-12T18:00:00",
"2026-09-01--2026-09-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
@@ -744,40 +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 saved_from == "2026-09-10T09:00:00"
assert datetime.strptime(saved_to, "%Y-%m-%dT%H:%M:%S")
assert call_args.args[3] == "datetime"
assert call_args.args[4] is True
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_naive_utc_to(
def test_commit_period_from_equals_window_end_year_boundary(
self, mock_save, mock_fetch, tmp_path
):
"""При precision=datetime --commit сохраняет to как naive UTC (#70)."""
from datetime import datetime
"""Окно до 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-09-10T09:00:00--2026-09-12T18:00:00",
"2026-12-01--2026-12-31",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
@@ -787,120 +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]
assert saved_from == "2026-09-10T09:00:00"
parsed = datetime.fromisoformat(saved_to)
assert parsed.tzinfo is None, f"{saved_to} must be naive UTC"
@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_datetime_persists_window_start_and_now(
self, mock_save, mock_fetch, tmp_path
):
"""--commit при datetime сохраняет начало окна и текущий naive UTC (#70)."""
from datetime import datetime as real_datetime
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}})
)
fake_datetime = mock.MagicMock()
fake_datetime.fromisoformat = real_datetime.fromisoformat
fake_datetime.strptime = real_datetime.strptime
fake_datetime.now.return_value = real_datetime(2026, 9, 15, 12, 0, 0)
with mock.patch("redmine_reporter.cli.datetime", fake_datetime):
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(
[
"--commit",
"--date",
"2026-09-10T09:00:00--2026-09-12T18:00:00",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path),
"2026-09-10T09:00:00",
"2026-09-15T12:00:00",
"datetime",
True,
)
@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")
@@ -918,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}",
@@ -956,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()
@@ -982,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(
[
@@ -1005,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
@@ -1203,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(
@@ -1221,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")
@@ -1231,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."""
@@ -1385,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"},
}
)
)
@@ -1425,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"},
}
)
)
@@ -1465,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"},
}
)
)
@@ -1581,45 +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: скользящее окно от last_used.to до now (#70).
Ожидаемое поведение после фикса #70: дефолтный диапазон начинается с
last_used.to и заканчивается текущим моментом, а не сдвинутым периодом.
"""
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"
)
real_dt = __import__("datetime").datetime
with mock.patch("redmine_reporter.config.datetime") as dt:
dt.fromisoformat = real_dt.fromisoformat
dt.now.return_value = real_dt(2026, 7, 15, 10, 0, 0)
code = main(["--config-path", str(config_path)])
assert code == 0
args, _ = mock_fetch.call_args
from_date, to_date = args[0], args[1]
assert from_date == "2026-06-30T23:59:59"
assert to_date == "2026-07-15T10:00:00"
# ---------------------------------------------------------------------------
# #55: sanitize Redmine exception output in CLI
# ---------------------------------------------------------------------------

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()
@@ -99,25 +96,6 @@ def test_get_default_date_range_fallback():
assert result == f"{start.isoformat()}--{today.isoformat()}"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_default_date_range_datetime_dynamic_sliding_window():
app = AppConfig(
period_precision="datetime",
period_dynamic=True,
period_last_used_to="2026-09-10T15:00:00+00:00",
)
Config._app = app
try:
real_dt = __import__("datetime").datetime
with mock.patch("redmine_reporter.config.datetime") as dt:
dt.fromisoformat = real_dt.fromisoformat
dt.now.return_value = real_dt(2026, 9, 15, 10, 0, 0)
rng = Config.get_default_date_range()
finally:
Config._app = None
assert rng == "2026-09-10T15:00:00--2026-09-15T10:00:00"
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) --
@@ -145,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():
@@ -318,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
@@ -363,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"
@@ -391,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 == []
@@ -404,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")
@@ -552,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)
@@ -589,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"