4 Commits

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

Closes #70
2026-09-15 15:40:20 +07:00
13 changed files with 728 additions and 1276 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import logging
import os import os
import re import re
import sys import sys
from datetime import datetime, timezone from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
@@ -39,35 +39,38 @@ def _sanitize_error_text(text: str) -> str:
return text 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]: def parse_date_range(date_arg: str) -> tuple[str, str]:
"""Возвращает границы диапазона в виде дат YYYY-MM-DD.
Каждая граница может быть датой или datetime; время отбрасывается
(границы окна -- целые дни), поэтому смешение форматов допустимо.
"""
if "--" not in date_arg: if "--" not in date_arg:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
parts = date_arg.split("--", 1) parts = date_arg.split("--", 1)
from_date, to_date = parts[0].strip(), parts[1].strip() from_date, to_date = parts[0].strip(), parts[1].strip()
date_pattern = r"\d{4}-\d{2}-\d{2}" start = _parse_boundary(from_date)
datetime_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}" end = _parse_boundary(to_date)
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")
try: if start.date() > end.date():
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:
raise ValueError("Date range start must be less than or equal to end") 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.date().isoformat(), end.date().isoformat()
return start.isoformat(), end.isoformat()
def _run_init_config(config_path: str, force: bool) -> int: 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, "verify_ssl": True,
}, },
"period": { "period": {
"precision": "date", "from": os.getenv("FROM_DATE", "").strip(),
"default_from": os.getenv("DEFAULT_FROM_DATE", "").strip(),
"default_to": os.getenv("DEFAULT_TO_DATE", "").strip(),
"dynamic": False,
}, },
"output": { "output": {
"dir": "", "dir": "",
@@ -155,29 +155,6 @@ def _run_init_config(config_path: str, force: bool) -> int:
return 0 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: def _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool:
"""Возвращает финальное значение no_time. """Возвращает финальное значение no_time.
@@ -201,10 +178,11 @@ def _save_and_maybe_send(
to_date: str, to_date: str,
no_time: bool, no_time: bool,
do_send: bool, do_send: bool,
) -> int: ) -> tuple[int, bool]:
"""Сохраняет отчёт в файл и опционально отправляет по email. """Сохраняет отчёт в файл и опционально отправляет по email.
Returns 0 on success, 1 on error. Returns (exit_code, sent): sent отражает факт реальной отправки письма,
чтобы main мог отличить отказ от подтверждения от успешной отправки.
""" """
output_ext = os.path.splitext(output_arg)[1].lower() output_ext = os.path.splitext(output_arg)[1].lower()
@@ -213,7 +191,7 @@ def _save_and_maybe_send(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx", "❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1, False
formatter = get_formatter_by_extension( formatter = get_formatter_by_extension(
output_ext, output_ext,
@@ -236,7 +214,7 @@ def _save_and_maybe_send(
f"Поддерживаются: {known_exts}", f"Поддерживаются: {known_exts}",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1, False
try: try:
formatter.save(rows, output_arg) formatter.save(rows, output_arg)
@@ -244,7 +222,7 @@ def _save_and_maybe_send(
except Exception as e: except Exception as e:
fmt = output_ext.lstrip(".").upper() fmt = output_ext.lstrip(".").upper()
print(f"❌ {fmt} export error: {e}", file=sys.stderr) print(f"❌ {fmt} export error: {e}", file=sys.stderr)
return 1 return 1, False
if do_send: if do_send:
email_config = Config.get_email_config() email_config = Config.get_email_config()
@@ -254,7 +232,7 @@ def _save_and_maybe_send(
"(~/.config/redmine-reporter/config.yml).", "(~/.config/redmine-reporter/config.yml).",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1, False
print("\n📧 Готово к отправке:") print("\n📧 Готово к отправке:")
print(f" From: {email_config.from_}") print(f" From: {email_config.from_}")
@@ -267,7 +245,7 @@ def _save_and_maybe_send(
response = input("Отправить? [y/N]: ").strip().lower() response = input("Отправить? [y/N]: ").strip().lower()
if response not in ("y", "yes"): if response not in ("y", "yes"):
print("⏭️ Отправка отменена.") print("⏭️ Отправка отменена.")
return 0 return 0, False
try: try:
send_report( send_report(
@@ -280,9 +258,10 @@ def _save_and_maybe_send(
print(f"📧 Report sent to {', '.join(email_config.to)}") print(f"📧 Report sent to {', '.join(email_config.to)}")
except RedmineAPIError as e: except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr) 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: def main(argv: Optional[List[str]] = None) -> int:
@@ -363,12 +342,12 @@ def main(argv: Optional[List[str]] = None) -> int:
parser.add_argument( parser.add_argument(
"--commit", "--commit",
action="store_true", 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( parser.add_argument(
"--send", "--send",
action="store_true", 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) args = parser.parse_args(argv)
@@ -442,7 +421,6 @@ def main(argv: Optional[List[str]] = None) -> int:
to_date, to_date,
user_id=args.user_id or args.user_login or args.user_name, user_id=args.user_id or args.user_login or args.user_name,
by_activity=args.by_activity, by_activity=args.by_activity,
dedup_before=_compute_dedup_cutoff(),
) )
except RedmineAPIError as e: except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr) 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] activity = key.split(":", 1)[1]
print(f" [{activity}]: {value}h", file=sys.stderr) print(f" [{activity}]: {value}h", file=sys.stderr)
save_ok = False
report_sent = False
if args.output or args.commit: if args.output or args.commit:
if args.output: if args.output:
output_arg = resolve_output_path( output_arg = resolve_output_path(
@@ -515,7 +496,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
return 1 return 1
ret = _save_and_maybe_send( ret, report_sent = _save_and_maybe_send(
rows, rows,
output_arg, output_arg,
author=Config.get_author(args.author), author=Config.get_author(args.author),
@@ -526,6 +507,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
if ret != 0: if ret != 0:
return ret return ret
save_ok = True
elif args.send: elif args.send:
# --send без --output и --commit: сохраняем по шаблону и отправляем # --send без --output и --commit: сохраняем по шаблону и отправляем
@@ -546,7 +528,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
return 1 return 1
ret = _save_and_maybe_send( ret, report_sent = _save_and_maybe_send(
rows, rows,
output_arg, output_arg,
author=Config.get_author(args.author), author=Config.get_author(args.author),
@@ -557,6 +539,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
if ret != 0: if ret != 0:
return ret return ret
save_ok = True
else: else:
if args.compact: if args.compact:
@@ -575,24 +558,17 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"❌ Formatting error: {e}", file=sys.stderr) print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1 return 1
if args.commit: # Окно двигается ровно один раз за запуск: либо по явному --commit
precision = Config.get_period_precision() # (операция завершилась кодом 0), либо по фактически отправленному письму --send.
dynamic = Config._app.period_dynamic if Config._app else False should_advance = (args.commit and save_ok) or (args.send and report_sent)
if should_advance:
if precision == "datetime": # Начало следующего окна совпадает с концом текущего: граничный день
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой # перечитывается. Дубли записей дня коммита осознанны, зато поздние
# метки aware cutoff для дедупликации. # записи, добавленные в тот же день после отправки, не теряются.
now = datetime.now(timezone.utc).isoformat(timespec="seconds") next_from = to_date
from_str = now
to_str = now
else:
from_str = from_date
to_str = to_date
try: try:
save_period_to_config( save_period_to_config(args.config_path, from_str=next_from)
args.config_path, from_str, to_str, precision, dynamic
)
except Exception as e: except Exception as e:
print( print(
f"❌ Не удалось сохранить период в конфиг: {e}", f"❌ Не удалось сохранить период в конфиг: {e}",
@@ -600,7 +576,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
return 1 return 1
print( print(
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}", f"📌 Period committed [{next_from}] → {args.config_path}",
file=sys.stderr, file=sys.stderr,
) )

View File

@@ -1,5 +1,4 @@
import sys import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
import requests import requests
@@ -163,33 +162,6 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str:
return str(activity) 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]: def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = [] all_issues: List[Issue] = []
@@ -278,18 +250,12 @@ def fetch_issues_with_spent_time(
to_date: str, to_date: str,
user_id: Optional[Union[int, str]] = None, user_id: Optional[Union[int, str]] = None,
by_activity: bool = False, by_activity: bool = False,
dedup_before: Optional[datetime] = None,
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]: ) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]:
""" """
Fetch unique issues linked to time entries of the given user in date range, Fetch unique issues linked to time entries of the given user in date range,
along with total spent hours per issue. along with total spent hours per issue.
If user_id is None, uses current user. If user_id is None, uses current user.
If by_activity is True, returns per-activity breakdown as third tuple element. 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. Returns list of (issue, total_hours, activities) tuples.
Raises RedmineAPIError on API/auth/network failures. 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 {} activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
time_entries = list( time_entries = list(
redmine.time_entry.filter( 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: except RedmineAPIError:
@@ -312,38 +280,6 @@ def fetch_issues_with_spent_time(
except Exception as exc: except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from 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 (и активности, если требуется) # Агрегируем часы по issue.id (и активности, если требуется)
spent_time: Dict[int, float] = {} spent_time: Dict[int, float] = {}
spent_by_activity: Dict[int, Dict[str, float]] = {} spent_by_activity: Dict[int, Dict[str, float]] = {}

View File

@@ -2,7 +2,7 @@ import logging
import os import os
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date, timedelta from datetime import date
from pathlib import Path from pathlib import Path
from typing import Dict, Union from typing import Dict, Union
@@ -40,24 +40,13 @@ class EmailConfig:
html: bool = False html: bool = False
@dataclass
class PeriodLastUsed:
from_: str = ""
to: str = ""
@dataclass @dataclass
class AppConfig: class AppConfig:
redmine_url: str = "" redmine_url: str = ""
redmine_api_key: str = "" redmine_api_key: str = ""
redmine_author: str = "" redmine_author: str = ""
redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY
period_precision: str = "date" period_from: str = ""
period_default_from: str = ""
period_default_to: str = ""
period_dynamic: bool = False
period_last_used_from: str = ""
period_last_used_to: str = ""
output_dir: str = "" output_dir: str = ""
output_filename: str = "{author}_{from}_{to}.{ext}" output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx" output_default_format: str = "xlsx"
@@ -100,12 +89,7 @@ class AppConfig:
redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"), redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"),
redmine_author=cls._resolve_str(raw, "redmine", "author"), redmine_author=cls._resolve_str(raw, "redmine", "author"),
redmine_verify=cls._resolve_verify(raw), redmine_verify=cls._resolve_verify(raw),
period_precision=cls._resolve_str(raw, "period", "precision") or "date", period_from=cls._resolve_str(raw, "period", "from"),
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"),
output_dir=cls._resolve_str(raw, "output", "dir"), output_dir=cls._resolve_str(raw, "output", "dir"),
output_filename=cls._resolve_str(raw, "output", "filename") output_filename=cls._resolve_str(raw, "output", "filename")
or "{author}_{from}_{to}.{ext}", or "{author}_{from}_{to}.{ext}",
@@ -118,17 +102,6 @@ class AppConfig:
email=cls._resolve_email(raw), 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 @staticmethod
def _resolve_str(raw: dict, section: str, *keys: str) -> str: def _resolve_str(raw: dict, section: str, *keys: str) -> str:
value = raw.get(section) value = raw.get(section)
@@ -365,24 +338,6 @@ class Config:
return cls._app.redmine_author return cls._app.redmine_author
return "" 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 @classmethod
def get_output_dir(cls) -> str: def get_output_dir(cls) -> str:
if cls._app: if cls._app:
@@ -432,33 +387,35 @@ class Config:
@classmethod @classmethod
def get_default_date_range(cls) -> str: def get_default_date_range(cls) -> str:
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip() """Окно отчёта по умолчанию: from..сегодня.
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
Приоритет начала: env FROM_DATE -> YAML period.from -> 1-е число
текущего месяца. Некорректная дата не роняет CLI -- логируется
warning и берётся текущий месяц.
"""
today = date.today() 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: if raw_from:
return f"{from_env}--{to_env or today_str}" 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 ( # Будущая start инвертирует диапазон и ломает фильтр Redmine,
cls._app # поэтому сжимаем окно до сегодняшнего дня.
and cls._app.period_dynamic if start > today:
and cls._app.period_last_used_from start = today
and cls._app.period_last_used_to return f"{start.isoformat()}--{today.isoformat()}"
):
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}"
@classmethod @classmethod
def validate(cls) -> None: def validate(cls) -> None:
@@ -483,52 +440,3 @@ class Config:
raise ValueError( raise ValueError(
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD" "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 return arg
def save_period_to_config( def save_period_to_config(config_path: str, from_str: str) -> None:
config_path: str, """Save committed period start to YAML config file.
from_str: str,
to_str: str,
precision: str,
dynamic: bool,
) -> None:
"""Save committed period to YAML config file.
Writes period.last_used.from / period.last_used.to. Writes period.from only: all other sections and keys stay untouched.
When dynamic=False, also overwrites period.default_from / period.default_to. Creates the config file (and its parent directory) if missing.
Preserves all other sections unchanged. Creates config file if missing.
""" """
import yaml import yaml
@@ -176,11 +168,7 @@ def save_period_to_config(
raw = yaml.safe_load(fh) or {} raw = yaml.safe_load(fh) or {}
period = raw.setdefault("period", {}) period = raw.setdefault("period", {})
period["last_used"] = {"from": from_str, "to": to_str} period["from"] = from_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
ensure_config_dir(path.parent) ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh: with open(path, "w", encoding="utf-8") as fh:

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")),
(" 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-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): 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-30--2026-03-01",
"2026-02-01--2026-01-31", "2026-02-01--2026-01-31",
"2026-06-01T25:00:00--2026-06-30T23:59:59", "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", "2026-06-30T23:59:59--2026-06-01T00:00:00",
], ],
) )
@@ -438,8 +443,7 @@ class TestInitConfig:
"REDMINE_URL=https://redmine.example.com\n" "REDMINE_URL=https://redmine.example.com\n"
"REDMINE_API_KEY=secret-from-dotenv\n" "REDMINE_API_KEY=secret-from-dotenv\n"
"REDMINE_AUTHOR=ivan.ivanov\n" "REDMINE_AUTHOR=ivan.ivanov\n"
"DEFAULT_FROM_DATE=2026-01-01\n" "FROM_DATE=2026-01-01\n"
"DEFAULT_TO_DATE=2026-01-31\n"
"SMTP_PASSWORD=smtp-secret\n" "SMTP_PASSWORD=smtp-secret\n"
) )
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
@@ -448,8 +452,7 @@ class TestInitConfig:
"REDMINE_URL", "REDMINE_URL",
"REDMINE_API_KEY", "REDMINE_API_KEY",
"REDMINE_AUTHOR", "REDMINE_AUTHOR",
"DEFAULT_FROM_DATE", "FROM_DATE",
"DEFAULT_TO_DATE",
"SMTP_PASSWORD", "SMTP_PASSWORD",
): ):
monkeypatch.delenv(var, raising=False) monkeypatch.delenv(var, raising=False)
@@ -464,8 +467,7 @@ class TestInitConfig:
assert data["redmine"]["url"] == "https://redmine.example.com" assert data["redmine"]["url"] == "https://redmine.example.com"
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}" assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
assert data["redmine"]["author"] == "ivan.ivanov" assert data["redmine"]["author"] == "ivan.ivanov"
assert data["period"]["default_from"] == "2026-01-01" assert data["period"]["from"] == "2026-01-01"
assert data["period"]["default_to"] == "2026-01-31"
assert data["email"]["smtp"]["password"] == "${SMTP_PASSWORD}" assert data["email"]["smtp"]["password"] == "${SMTP_PASSWORD}"
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @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.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config") @mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_saves_period_to_config(self, mock_save, mock_fetch, tmp_path): def test_commit_saves_period_to_config(self, mock_save, mock_fetch, tmp_path):
"""--commit сохраняет период в YAML-конфиг.""" """--commit пишет в period.from конец окна отчёта (день коммита)."""
import yaml import yaml
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)] mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}}))
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
@@ -705,34 +705,29 @@ class TestCommitFlag:
) )
assert code == 0 assert code == 0
mock_save.assert_called_once_with( mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30")
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config") @mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_with_precision_datetime_passes_datetime_strings( def test_commit_period_from_equals_window_end(
self, mock_save, mock_fetch, tmp_path self, mock_save, mock_fetch, tmp_path
): ):
"""При precision=datetime --commit сохраняет timestamps.""" """Конец окна 2026-09-30 -- period.from ровно 2026-09-30, не 2026-10-01."""
import yaml import yaml
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)] mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( config_path.write_text(yaml.dump({"period": {"from": "2026-09-01"}}))
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_get.return_value = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main( code = main(
[ [
"--date", "--date",
"2026-06-30--2026-06-30", "2026-09-01--2026-09-30",
"--commit", "--commit",
"--output", "--output",
str(tmp_path / "report.xlsx"), str(tmp_path / "report.xlsx"),
@@ -742,38 +737,29 @@ class TestCommitFlag:
) )
assert code == 0 assert code == 0
call_args = mock_save.call_args mock_save.assert_called_once_with(str(config_path), from_str="2026-09-30")
assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2]
assert "T" in saved_from
assert "T" in saved_to
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config") @mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_with_precision_datetime_saves_aware_utc( def test_commit_period_from_equals_window_end_year_boundary(
self, mock_save, mock_fetch, tmp_path self, mock_save, mock_fetch, tmp_path
): ):
"""При precision=datetime --commit сохраняет last_used как aware UTC (#58).""" """Окно до 2026-12-31 -- from_str равен 2026-12-31: timedelta-математики нет."""
from datetime import datetime, timezone
import yaml import yaml
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)] mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( config_path.write_text(yaml.dump({"period": {"from": "2026-12-01"}}))
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_get.return_value = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main( code = main(
[ [
"--date", "--date",
"2026-06-30--2026-06-30", "2026-12-01--2026-12-31",
"--commit", "--commit",
"--output", "--output",
str(tmp_path / "report.xlsx"), str(tmp_path / "report.xlsx"),
@@ -783,70 +769,7 @@ class TestCommitFlag:
) )
assert code == 0 assert code == 0
call_args = mock_save.call_args mock_save.assert_called_once_with(str(config_path), from_str="2026-12-31")
assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2]
for saved in (saved_from, saved_to):
parsed = datetime.fromisoformat(saved)
assert parsed.tzinfo is not None, f"{saved} must be timezone-aware"
assert parsed.utcoffset() == timezone.utc.utcoffset(None)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
"""Без записей --commit не пишет конфиг."""
mock_fetch.return_value = None
config_path = tmp_path / "config.yml"
config_path.write_text("period:\n precision: date\n dynamic: true\n")
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_dynamic_false_overwrites_defaults(
self, mock_save, mock_fetch, tmp_path
):
"""При dynamic=false --commit перезаписывает default_from/to."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": False}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path), "2026-06-01", "2026-06-30", "date", False
)
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@@ -864,7 +787,7 @@ class TestCommitFlag:
config_path.write_text( config_path.write_text(
yaml.dump( yaml.dump(
{ {
"period": {"precision": "date", "dynamic": True}, "period": {"from": "2026-06-01"},
"output": { "output": {
"dir": str(tmp_path / "reports"), "dir": str(tmp_path / "reports"),
"filename": "report_{date}.{ext}", "filename": "report_{date}.{ext}",
@@ -902,9 +825,7 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0, None)] mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}}))
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
@@ -928,16 +849,12 @@ class TestCommitFlag:
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config") @mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path): def test_commit_no_entries_does_not_save(self, mock_save, mock_fetch, tmp_path):
"""Пустой список задач — --commit не сохраняет конфиг.""" """Без записей --commit не пишет конфиг."""
import yaml mock_fetch.return_value = None
mock_fetch.return_value = []
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( config_path.write_text("period:\n from: '2026-01-01'\n")
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
code = main( code = main(
[ [
@@ -951,6 +868,64 @@ class TestCommitFlag:
assert code == 0 assert code == 0
mock_save.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.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 # #45: --send tests
@@ -1149,8 +1124,7 @@ class TestSendFlag:
" to:\n" " to:\n"
" - boss@example.com\n" " - boss@example.com\n"
"period:\n" "period:\n"
" precision: date\n" " from: '2026-06-01'\n"
" dynamic: true\n"
) )
code = main( code = main(
@@ -1167,7 +1141,7 @@ class TestSendFlag:
) )
assert code == 0 assert code == 0
mock_send.assert_called_once() 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.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@@ -1177,6 +1151,192 @@ class TestSendFlag:
code = main(["--date", "2026-06-01--2026-06-30", "--send"]) code = main(["--date", "2026-06-01--2026-06-30", "--send"])
assert code == 0 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: class TestSendHtmlBody:
"""Tests for email.html body generation.""" """Tests for email.html body generation."""
@@ -1331,7 +1491,7 @@ class TestReportNoTimeIntegration:
{ {
"redmine": {"url": "https://x.com", "api_key": "token"}, "redmine": {"url": "https://x.com", "api_key": "token"},
"report": {"no_time": True}, "report": {"no_time": True},
"period": {"precision": "date", "dynamic": True}, "period": {"from": "2026-06-01"},
} }
) )
) )
@@ -1371,7 +1531,7 @@ class TestReportNoTimeIntegration:
{ {
"redmine": {"url": "https://x.com", "api_key": "token"}, "redmine": {"url": "https://x.com", "api_key": "token"},
"report": {"no_time": False}, "report": {"no_time": False},
"period": {"precision": "date", "dynamic": True}, "period": {"from": "2026-06-01"},
} }
) )
) )
@@ -1411,7 +1571,7 @@ class TestReportNoTimeIntegration:
{ {
"redmine": {"url": "https://x.com", "api_key": "token"}, "redmine": {"url": "https://x.com", "api_key": "token"},
"report": {"no_time": False}, "report": {"no_time": False},
"period": {"precision": "date", "dynamic": True}, "period": {"from": "2026-06-01"},
} }
) )
) )
@@ -1527,43 +1687,6 @@ class TestReportNoTimeIntegration:
assert kwargs.get("no_time") is True assert kwargs.get("no_time") is True
# ---------------------------------------------------------------------------
# #59: dynamic + precision=datetime без --date
# ---------------------------------------------------------------------------
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
"""dynamic+datetime без --date: datetime-диапазон из last_used принимается.
Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон
с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used,
отчёт строится без ошибки.
"""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
"period:\n"
" precision: datetime\n"
" dynamic: true\n"
" last_used:\n"
" from: '2026-06-01T00:00:00'\n"
" to: '2026-06-30T23:59:59'\n"
)
code = main(["--config-path", str(config_path)])
assert code == 0
args, _ = mock_fetch.call_args
from_date, to_date = args[0], args[1]
# Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59
assert from_date == "2026-07-01T00:00:00"
assert to_date == "2026-07-30T23:59:59"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# #55: sanitize Redmine exception output in CLI # #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 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 записей) -- # -- Пагинация 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 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: предупреждения о молчаливой потере данных -- # -- #61: предупреждения о молчаливой потере данных --
@@ -1153,3 +760,48 @@ def test_load_time_entry_activities_returns_empty_on_failure(capsys):
captured = capsys.readouterr() captured = capsys.readouterr()
assert "activit" in captured.err.lower() 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 os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -8,6 +9,18 @@ import pytest
from redmine_reporter.config import AppConfig, Config, EmailConfig 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( @mock.patch.dict(
os.environ, os.environ,
{ {
@@ -62,25 +75,9 @@ def test_get_author_fallback():
assert Config.get_author("") == "" assert Config.get_author("") == ""
@mock.patch.dict( @mock.patch.dict(os.environ, {"FROM_DATE": "2026-01-01"}, clear=True)
os.environ,
{
"DEFAULT_FROM_DATE": "2026-01-01",
"DEFAULT_TO_DATE": "2026-01-31",
},
clear=True,
)
def test_get_default_date_range_from_env(): def test_get_default_date_range_from_env():
assert Config.get_default_date_range() == "2026-01-01--2026-01-31" """FROM_DATE задаёт начало периода, конец всегда -- сегодня."""
@mock.patch.dict(
os.environ,
{"DEFAULT_FROM_DATE": "2026-01-01"},
clear=True,
)
def test_get_default_date_range_from_env_without_to():
"""Если DEFAULT_TO_DATE не задан, конец периода — сегодня."""
from datetime import date from datetime import date
today = date.today().isoformat() today = date.today().isoformat()
@@ -126,31 +123,98 @@ class TestDefaultPeriodIsCurrentMonth:
assert Config.get_default_date_range() == "2026-05-01--2026-05-01" assert Config.get_default_date_range() == "2026-05-01--2026-05-01"
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_default_from_falls_back_to_today(self): def test_yaml_period_from_runs_to_today(self):
"""default_from задан, default_to нет → конец периода = сегодня.""" """period.from задан -- конец периода всегда сегодня."""
from datetime import date from datetime import date
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml" 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) Config._app = AppConfig.from_yaml(yaml_path)
with mock.patch("redmine_reporter.config.date") as mock_date: with mock_today(date(2026, 4, 20)):
mock_date.today.return_value = date(2026, 4, 20)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
assert Config.get_default_date_range() == "2026-04-01--2026-04-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): class TestDefaultDateRangePeriodFrom:
"""DEFAULT_FROM_DATE задана, DEFAULT_TO_DATE нет → конец = сегодня.""" """Приоритет окна: 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 from datetime import date
Config._app = None Config._app = None
with mock.patch("redmine_reporter.config.date") as mock_date: with mock_today(date(2026, 2, 10)):
mock_date.today.return_value = date(2026, 2, 10)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
assert Config.get_default_date_range() == "2026-02-01--2026-02-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) @mock.patch.dict(os.environ, {}, clear=True)
def test_get_redmine_verify_default(): def test_get_redmine_verify_default():
@@ -299,13 +363,7 @@ redmine:
verify_ssl: false verify_ssl: false
period: period:
precision: datetime from: "2026-06-01"
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"
output: output:
dir: ~/reports dir: ~/reports
@@ -344,12 +402,7 @@ class TestAppConfigFromYaml:
assert cfg.redmine_api_key == "" # ${MISSING_API_KEY_FOR_TEST} not set assert cfg.redmine_api_key == "" # ${MISSING_API_KEY_FOR_TEST} not set
assert cfg.redmine_author == "Тестовый Автор" assert cfg.redmine_author == "Тестовый Автор"
assert cfg.redmine_verify is False assert cfg.redmine_verify is False
assert cfg.period_precision == "datetime" assert cfg.period_from == "2026-06-01"
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.output_dir == "~/reports" assert cfg.output_dir == "~/reports"
assert cfg.output_filename == "{author}_{from}_{to}.{ext}" assert cfg.output_filename == "{author}_{from}_{to}.{ext}"
assert cfg.output_default_format == "xlsx" assert cfg.output_default_format == "xlsx"
@@ -372,7 +425,7 @@ class TestAppConfigFromYaml:
assert cfg.redmine_url == "https://x.com/" assert cfg.redmine_url == "https://x.com/"
# defaults for everything else # defaults for everything else
assert cfg.redmine_author == "" assert cfg.redmine_author == ""
assert cfg.period_precision == "date" assert cfg.period_from == ""
assert cfg.output_dir == "" assert cfg.output_dir == ""
assert cfg.email.to == [] assert cfg.email.to == []
@@ -385,7 +438,36 @@ class TestAppConfigFromYaml:
assert cfg.redmine_url == "" assert cfg.redmine_url == ""
assert cfg.redmine_api_key == "" 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): def test_file_not_found_returns_all_defaults(self):
cfg = AppConfig.from_yaml("/nonexistent/path/config.yml") cfg = AppConfig.from_yaml("/nonexistent/path/config.yml")
@@ -533,14 +615,15 @@ class TestConfigYamlFallback:
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_date_range_from_yaml(self): def test_date_range_from_yaml(self):
from datetime import date
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml" yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text( yaml_path.write_text("period:\n from: '2026-03-01'\n")
"period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n"
)
Config._app = AppConfig.from_yaml(yaml_path) 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" assert Config.get_default_date_range() == "2026-03-01--2026-03-15"
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
@@ -570,205 +653,17 @@ class TestConfigYamlFallback:
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_default_date_range_env_beats_yaml(self): 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 from datetime import date
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml" yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text( yaml_path.write_text("period:\n from: '2026-03-01'\n")
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-04-01'\n"
)
Config._app = AppConfig.from_yaml(yaml_path) Config._app = AppConfig.from_yaml(yaml_path)
today = date.today().isoformat()
result = Config.get_default_date_range() with mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True):
assert result == f"2026-04-01--{today}" with mock_today(date(2026, 7, 15)):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
class TestGetEmailConfig: class TestGetEmailConfig:

View File

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