6 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
95afaf05a1 Merge branch 'feature/71-my-tasks'
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-10-28 15:57:39 +07:00
Кокос Артем Николаевич
375ea13a1f chore: bump version to 2.1.0 2026-10-28 15:55:53 +07:00
Кокос Артем Николаевич
f00c6705ba feat: filter Wishlist from --my-tasks; document the mode (#71)
Tasks in the "Wishlist" bucket version are now excluded from the list
and summary entirely (no due date, noise backlog); exact case match,
tasks without a version are unaffected. Document --my-tasks in README
and the user guide (sorting, summary metrics, OSC 8 links, stream
separation); all examples use synthetic data. Declare ruff
known-first-party in pyproject so the lint result is CWD-independent.
2026-10-28 15:53:50 +07:00
Кокос Артем Николаевич
5d1cf4ab74 feat: add --my-tasks mode for open assigned issues (#71)
List open Redmine issues assigned to the current (or overridden) user,
sorted by priority rank (P1..P5) then version due date with a stable id
tie-break. Renders an OSC 8 hyperlinked console table on stdout and a
counts/hours summary line on stderr; version due dates are fetched once
per unique version, unavailable versions degrade to an empty due date.

Also migrates the codebase to ruff 0.17.3 defaults (PEP 585/604
annotations, targeted noqa for intentional blind excepts and CLI root
logging). Behavior-neutral: 408 tests pass with -W error.
2026-10-28 15:26:41 +07:00
Кокос Артем Николаевич
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
33 changed files with 2188 additions and 1486 deletions

View File

@@ -19,9 +19,10 @@ 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-письмо) с авто-сдвигом начала периода при успешной отправке.
- `--my-tasks`: список назначенных вам открытых задач -- таблица с сортировкой по срочности (приоритет, срок версии) и сводкой; см. [руководство](docs/USER_GUIDE.md#список-открытых-задач).
- YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md). - YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md).
## Установка ## Установка
@@ -55,8 +56,11 @@ vim ~/.config/redmine-reporter/config.yml
# Отчёт за текущий месяц в консоль # Отчёт за текущий месяц в консоль
redmine-reporter redmine-reporter
# Сохранить в файл и зафиксировать период для следующего запуска # Сохранить в файл и сдвинуть начало периода для следующего запуска
redmine-reporter --commit redmine-reporter --commit
# Список ваших открытых задач (сортировка по срочности)
redmine-reporter --my-tasks
``` ```
## Документация ## Документация

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

@@ -6,10 +6,11 @@
- [Первоначальная настройка](#первоначальная-настройка) - [Первоначальная настройка](#первоначальная-настройка)
- [Выбор периода](#выбор-периода) - [Выбор периода](#выбор-периода)
- [Отчёт за другого пользователя](#отчёт-за-другого-пользователя) - [Отчёт за другого пользователя](#отчёт-за-другого-пользователя)
- [Список открытых задач](#список-открытых-задач)
- [Экспорт в файл](#экспорт-в-файл) - [Экспорт в файл](#экспорт-в-файл)
- [Содержимое отчёта](#содержимое-отчёта) - [Содержимое отчёта](#содержимое-отчёта)
- [Отправка по email](#отправка-по-email) - [Отправка по email](#отправка-по-email)
- [Ежемесячный цикл с --commit](#ежемесячный-цикл-с---commit) - [Скользящее окно периода](#скользящее-окно-периода)
- [Справочник флагов](#справочник-флагов) - [Справочник флагов](#справочник-флагов)
- [Устранение неполадок](#устранение-неполадок) - [Устранение неполадок](#устранение-неполадок)
- [См. также](#см-также) - [См. также](#см-также)
@@ -68,16 +69,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 +85,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 + текущий месяц.
## Отчёт за другого пользователя ## Отчёт за другого пользователя
@@ -109,6 +116,70 @@ redmine-reporter --user-name "Иванов Иван Иванович"
- Несколько совпадений по имени → ошибка «Multiple users match...» со списком логинов (при дублях логина — ID). - Несколько совпадений по имени → ошибка «Multiple users match...» со списком логинов (при дублях логина — ID).
- Ноль совпадений → ошибка «User '...' not found...». - Ноль совпадений → ошибка «User '...' not found...».
## Список открытых задач
`--my-tasks` -- отдельный режим: вместо отчёта по затраченному времени выводит
открытые задачи, назначенные на вас, по всем проектам. Отчётное окно периода
к режиму не применяется.
```bash
redmine-reporter --my-tasks # задачи владельца API-ключа
redmine-reporter --my-tasks --user-id 42 # чужой список по ID
redmine-reporter --my-tasks --user-login ivanov # по логину
```
Правила:
- `--user-id` / `--user-login` / `--user-name` выбирают, чей список показать; по умолчанию -- владелец API-ключа. Разрешение пользователей то же, что для отчёта (см. [Отчёт за другого пользователя](#отчёт-за-другого-пользователя)).
- `--url`, `--api-key`, `--config`, `--config-path`, `--verbose`, `--debug` работают как обычно.
- Совмещение с отчётными флагами (`--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`) -- ошибка «--my-tasks cannot be used with report-generating flags.», exit 1.
- Задачи в версии `Wishlist` исключаются из режима целиком: не попадают ни в таблицу, ни в сводку.
Сортировка -- от срочного к менее срочному:
1. Приоритет `P1..P5`: меньший номер срочнее; неизвестное имя приоритета получает низший ранг.
2. Внутри приоритетной группы -- срок версии (due date) по возрастанию.
3. Задачи без версии или без срока -- в конец своей приоритетной группы.
4. При прочих равных -- по возрастанию ID.
Таблица печатается в stdout, сводка -- в stderr перед таблицей. При
перенаправлении (`redmine-reporter --my-tasks > tasks.txt`) в файл попадает
только таблица.
Колонки таблицы: ID | Тема | Статус | Приор. | Версия | Срок. Пример
(синтетические данные, ID в терминале кликабелен):
```
ID Тема Статус Приор. Версия Срок
---- -------------------------------- -------------- -------- -------- --------------
101 Критический дефект сборки образа В работе P1 v2.4.0 2026-10-15 (!)
102 Обновить документацию API Новая P2 v2.5.0 2026-12-01
103 Ревью pull request Обратная связь P3 - -
```
- ID -- гиперссылка (OSC 8) на страницу задачи `<url>/issues/<id>`: в терминалах с поддержкой кликабельна, без поддержки остаётся обычным номером.
- Срок -- дата релиза целевой версии; просроченный (раньше сегодняшней даты) помечается `(!)`.
- Пустые «Версия» и «Срок» -- прочерк `-`.
- Статусы переводятся на русский тем же словарём, что и в отчётах: встроенный словарь + `report.status_translation` (см. [Перевод статусов](#перевод-статусов)).
Сводка (пример с синтетическими данными):
```
Открытых: 3 | просроченных: 1 | без версии: 1 | без срока: 0 | потрачено: 12.5h | оценка: 8h (по 2 из 3)
```
- «просроченных» -- задачи со сроком версии раньше сегодняшней даты.
- «без версии» -- задачи без целевой версии; «без срока» -- задачи с версией, но без известной даты релиза.
- «потрачено» -- сумма полей `spent_hours` задач списка.
- «оценка» -- сумма полей `estimated_hours`; «(по N из M)» -- у скольких задач из списка есть оценка. Если оценок нет ни у одной задачи, блок «оценка» не выводится.
Сроки версий дозагружаются отдельным запросом на каждую уникальную версию
списка. Недоступная версия -- предупреждение в stderr и пустой срок у её
задач, вывод не прерывается.
Пустой список (в том числе когда все открытые задачи попали в исключаемую
версию `Wishlist`): «ℹ️ Открытых задач нет.» (stderr), exit 0.
## Экспорт в файл ## Экспорт в файл
Флаг `--output` определяется по четырём правилам: Флаг `--output` определяется по четырём правилам:
@@ -206,6 +277,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 +293,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 +347,9 @@ 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 после сохранения файла; при подтверждении и успехе сдвинуть начало периода. |
| `--my-tasks` | Вместо отчёта вывести открытые задачи, назначенные на вас (или на пользователя из `--user-id`/`--user-login`/`--user-name`): таблица с сортировкой по срочности + сводка в stderr. Несовместим с `--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`. Подробнее: [Список открытых задач](#список-открытых-задач). |
## Устранение неполадок ## Устранение неполадок
@@ -285,6 +360,7 @@ redmine-reporter --send --commit
| «User '...' not found...» | Пользователь не найден ни по ID, ни по логину, ни по имени | Проверьте написание; логин сравнивается точно с учётом регистра. | | «User '...' not found...» | Пользователь не найден ни по ID, ни по логину, ни по имени | Проверьте написание; логин сравнивается точно с учётом регистра. |
| «Multiple users match...» | По имени найдено несколько пользователей (или несколько дублей логина) | Используйте `--user-login` с логином из списка или уточните числовой ID для `--user-id`. | | «Multiple users match...» | По имени найдено несколько пользователей (или несколько дублей логина) | Используйте `--user-login` с логином из списка или уточните числовой ID для `--user-id`. |
| Указано несколько `--user-*` | Флаги взаимоисключающие | Оставьте только один из `--user-id` / `--user-login` / `--user-name`. | | Указано несколько `--user-*` | Флаги взаимоисключающие | Оставьте только один из `--user-id` / `--user-login` / `--user-name`. |
| «--my-tasks cannot be used with report-generating flags.» (exit 1) | `--my-tasks` совмещён с отчётными флагами (`--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`) | Запускайте режим отдельно: список задач и отчёт -- разные команды (см. [Список открытых задач](#список-открытых-задач)). |
| «Неизвестный формат файла: '.xyz'. Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx» (exit 1) | `--output` с неизвестным расширением | Используйте одно из поддерживаемых расширений. | | «Неизвестный формат файла: '.xyz'. Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx» (exit 1) | `--output` с неизвестным расширением | Используйте одно из поддерживаемых расширений. |
| Ошибка про `odfpy` при `.odt` | Пакет `odfpy` не установлен | `pip install odfpy` или выберите другой формат. | | Ошибка про `odfpy` при `.odt` | Пакет `odfpy` не установлен | `pip install odfpy` или выберите другой формат. |
| «Email не настроен. Добавьте секцию 'email' в конфиг...» (exit 1) | `--send` без секции `email` в YAML или с пустым `smtp.host` | Добавьте секцию `email` (см. [CONFIG.md](CONFIG.md)). | | «Email не настроен. Добавьте секцию 'email' в конфиг...» (exit 1) | `--send` без секции `email` в YAML или с пустым `smtp.host` | Добавьте секцию `email` (см. [CONFIG.md](CONFIG.md)). |
@@ -294,6 +370,7 @@ redmine-reporter --send --commit
| «Ошибка отправки письма: ...» | Прочая ошибка SMTP | Смотрите детали после двоеточия; файл отчёта уже сохранён на диске. | | «Ошибка отправки письма: ...» | Прочая ошибка SMTP | Смотрите детали после двоеточия; файл отчёта уже сохранён на диске. |
| Предупреждение о правах конфига при старте | Права `config.yml` шире `0600` | `chmod 600 ~/.config/redmine-reporter/config.yml`. | | Предупреждение о правах конфига при старте | Права `config.yml` шире `0600` | `chmod 600 ~/.config/redmine-reporter/config.yml`. |
| Предупреждение о MITM при старте | `verify_ssl: false` / `REDMINE_VERIFY=false` — проверка TLS отключена | Включите проверку или используйте только в доверенной сети. | | Предупреждение о MITM при старте | `verify_ssl: false` / `REDMINE_VERIFY=false` — проверка TLS отключена | Включите проверку или используйте только в доверенной сети. |
| «⚠️ Could not load version ...: Due date will be empty for its issues.» в режиме `--my-tasks` | Версия из списка задач недоступна (нет прав, удалена, сбой сети) | Некритично: срок этой версии останется пустым; проверьте права доступа к проекту версии. |
| Не виден traceback при ошибке | Traceback показывается только с `--verbose`/`--debug` | Повторите запуск с `--debug`. | | Не виден traceback при ошибке | Traceback показывается только с `--verbose`/`--debug` | Повторите запуск с `--debug`. |
API-ключ в текстах ошибок маскируется (`***`). API-ключ в текстах ошибок маскируется (`***`).

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "redmine-reporter" name = "redmine-reporter"
version = "1.12.1" version = "2.1.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" }]
@@ -62,3 +62,6 @@ ignore_missing_imports = true
[tool.isort] [tool.isort]
profile = "black" profile = "black"
[tool.ruff.lint.isort]
known-first-party = ["redmine_reporter"]

View File

@@ -1 +1 @@
__version__ = "1.12.1" __version__ = "2.1.0"

View File

@@ -3,19 +3,25 @@ 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
import yaml import yaml
from dotenv import find_dotenv, load_dotenv from dotenv import find_dotenv, load_dotenv
from . import __version__ from . import __version__
from .client import RedmineAPIError, fetch_issues_with_spent_time from .client import RedmineAPIError, fetch_issues_with_spent_time, fetch_open_issues
from .config import Config from .config import Config
from .formatters.factory import get_console_formatter, get_formatter_by_extension from .formatters.factory import get_console_formatter, get_formatter_by_extension
from .formatters.task_table import TaskTableFormatter
from .mailer import send_report from .mailer import send_report
from .report_builder import build_grouped_report, calculate_summary from .report_builder import build_grouped_report, calculate_summary
from .task_list import (
build_task_rows,
calculate_task_summary,
filter_excluded_versions,
format_summary_line,
)
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
# Маскируем API-ключ Redmine в тексте ошибок (#55): # Маскируем API-ключ Redmine в тексте ошибок (#55):
@@ -39,35 +45,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 +108,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 +161,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 +184,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 +197,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,
@@ -230,21 +214,20 @@ def _save_and_maybe_send(
file=sys.stderr, file=sys.stderr,
) )
else: else:
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
print( print(
f"❌ Неизвестный формат файла: {output_ext!r}. " f"❌ Неизвестный формат файла: {output_ext!r}. "
f"Поддерживаются: {known_exts}", "Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1, False
try: try:
formatter.save(rows, output_arg) formatter.save(rows, output_arg)
print(f"✅ Report saved to {output_arg}") print(f"✅ Report saved to {output_arg}")
except Exception as e: except Exception as e: # noqa: BLE001 -- форматёры (odfpy/openpyxl/csv) бросают произвольные типы, CLI обязан вернуть код 1 на любой сбой
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 +237,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 +250,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,12 +263,57 @@ 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, False
def _run_my_tasks(args: argparse.Namespace) -> int:
"""Режим --my-tasks: таблица открытых задач пользователя в stdout (#71).
Саммари печатается в stderr, а не в stdout: при перенаправлении вывода
в файл остаётся только таблица без служебных строк.
"""
try:
items = fetch_open_issues(
user_id=args.user_id or args.user_login or args.user_name
)
except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
if (args.verbose or args.debug) and e.original is not None:
# Точка входа CLI: root-логгер осознанно настроен через basicConfig
# в main(), module-логгер здесь дал бы второй набор хендлеров.
logging.exception("Original Redmine API error") # noqa: LOG015
return 1
except Exception as e:
print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
if args.verbose or args.debug:
logging.exception("Unexpected error") # noqa: LOG015
return 1
# Бакет-версии (Wishlist) исключаются до проверки на пустоту: если все
# открытые задачи в них, пользователь видит "Открытых задач нет".
items = filter_excluded_versions(items)
if not items:
print("ℹ️ Открытых задач нет.", file=sys.stderr)
return 0
rows = build_task_rows(items, Config.get_status_translation())
summary = calculate_task_summary(items)
print(format_summary_line(summary), file=sys.stderr)
print(file=sys.stderr)
try:
table = TaskTableFormatter(base_url=Config.get_redmine_url()).format(rows)
except Exception as e: # noqa: BLE001 -- сбой рендера таблицы не должен ронять CLI трейсбеком: печатаем ошибку и возвращаем код 1
print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1
print(table)
return 0 return 0
def main(argv: Optional[List[str]] = None) -> int: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="redmine-reporter", prog="redmine-reporter",
description="Generate Redmine issue report based on your time entries.", description="Generate Redmine issue report based on your time entries.",
@@ -363,12 +391,17 @@ 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",
)
parser.add_argument(
"--my-tasks",
action="store_true",
help="List open issues assigned to you, sorted by urgency, and exit",
) )
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -395,6 +428,25 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1 return 1
return _run_init_config(args.config_path, args.force) return _run_init_config(args.config_path, args.force)
# --my-tasks: отдельный режим, отчётное окно и файлы к нему неприменимы
if args.my_tasks:
report_flags = [
args.date is not None,
args.output,
args.compact,
args.summary,
args.no_time,
args.by_activity,
args.commit,
args.send,
]
if any(report_flags):
print(
"❌ --my-tasks cannot be used with report-generating flags.",
file=sys.stderr,
)
return 1
# Валидация взаимоисключающих флагов пользователя # Валидация взаимоисключающих флагов пользователя
user_args = [args.user_id, args.user_login, args.user_name] user_args = [args.user_id, args.user_login, args.user_name]
if sum(bool(a) for a in user_args) > 1: if sum(bool(a) for a in user_args) > 1:
@@ -428,6 +480,10 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"❌ Configuration error: {e}", file=sys.stderr) print(f"❌ Configuration error: {e}", file=sys.stderr)
return 1 return 1
# Ветка --my-tasks: даты отчётного окна режиму не нужны.
if args.my_tasks:
return _run_my_tasks(args)
# Если --date не указан, используем дефолтный диапазон # Если --date не указан, используем дефолтный диапазон
date_arg = args.date if args.date is not None else Config.get_default_date_range() date_arg = args.date if args.date is not None else Config.get_default_date_range()
try: try:
@@ -442,17 +498,18 @@ 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)
if (args.verbose or args.debug) and e.original is not None: if (args.verbose or args.debug) and e.original is not None:
logging.exception("Original Redmine API error") # Точка входа CLI: root-логгер осознанно настроен через basicConfig
# в main(), module-логгер здесь дал бы второй набор хендлеров.
logging.exception("Original Redmine API error") # noqa: LOG015
return 1 return 1
except Exception as e: except Exception as e:
print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr) print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
if args.verbose or args.debug: if args.verbose or args.debug:
logging.exception("Unexpected error") logging.exception("Unexpected error") # noqa: LOG015
return 1 return 1
if not issue_hours: if not issue_hours:
@@ -485,6 +542,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 +575,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 +586,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 +607,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 +618,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:
@@ -571,35 +633,29 @@ def main(argv: Optional[List[str]] = None) -> int:
try: try:
output = formatter.format(rows) output = formatter.format(rows)
print(output) print(output)
except Exception as e: except Exception as e: # noqa: BLE001 -- консольный форматтер не должен ронять CLI: печатаем ошибку и возвращаем код 1
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": # Начало следующего окна совпадает с концом текущего: граничный день
# from -- фактическое начало окна отчёта; to -- дедуп-маркер (naive UTC). # перечитывается. Дубли записей дня коммита осознанны, зато поздние
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") # записи, добавленные в тот же день после отправки, не теряются.
from_str = from_date next_from = to_date
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: # noqa: BLE001 -- сбой записи конфига не должен ронять CLI трейсбеком: сообщаем и возвращаем код 1
)
except Exception as e:
print( print(
f"❌ Не удалось сохранить период в конфиг: {e}", f"❌ Не удалось сохранить период в конфиг: {e}",
file=sys.stderr, file=sys.stderr,
) )
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,6 +1,6 @@
import sys import sys
from datetime import datetime, timezone from datetime import date
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any
import requests import requests
import truststore import truststore
@@ -21,13 +21,13 @@ ISSUE_ID_CHUNK_SIZE = 100
class RedmineAPIError(Exception): class RedmineAPIError(Exception):
"""Пользовательское исключение с понятным сообщением об ошибке Redmine API.""" """Пользовательское исключение с понятным сообщением об ошибке Redmine API."""
def __init__(self, message: str, original: Optional[Exception] = None): def __init__(self, message: str, original: Exception | None = None):
super().__init__(message) super().__init__(message)
self.message = message self.message = message
self.original = original self.original = original
def _get_redmine_auth_kwargs() -> Dict[str, Any]: def _get_redmine_auth_kwargs() -> dict[str, Any]:
"""Return Redmine auth kwargs. API key has priority over legacy password auth.""" """Return Redmine auth kwargs. API key has priority over legacy password auth."""
api_key = Config.get_redmine_api_key() api_key = Config.get_redmine_api_key()
if api_key: if api_key:
@@ -123,7 +123,7 @@ def _format_redmine_error(exc: Exception) -> str:
return str(exc) return str(exc)
def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]: def _load_time_entry_activities(redmine: Redmine) -> dict[int, str]:
"""Загружает справочник типов активности time entries. """Загружает справочник типов активности time entries.
Возвращает словарь id -> name. Если справочник недоступен, Возвращает словарь id -> name. Если справочник недоступен,
@@ -132,7 +132,7 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
try: try:
activities = redmine.enumeration.filter(resource="time_entry_activities") activities = redmine.enumeration.filter(resource="time_entry_activities")
return {int(a.id): str(a.name) for a in activities} return {int(a.id): str(a.name) for a in activities}
except Exception as exc: except Exception as exc: # noqa: BLE001 -- python-redmine бросает разные типы; справочник не критичен, деградируем к данным из entries
# #61: не глотаем сбой молча — предупреждаем, что разбивка по # #61: не глотаем сбой молча — предупреждаем, что разбивка по
# активностям будет построена по сырым данным из самих entries. # активностям будет построена по сырым данным из самих entries.
print( print(
@@ -143,7 +143,7 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
return {} return {}
def _get_activity_name(entry, activities: Dict[int, str]) -> str: def _get_activity_name(entry, activities: dict[int, str]) -> str:
"""Определяет название активности для time entry.""" """Определяет название активности для time entry."""
activity = getattr(entry, "activity", None) activity = getattr(entry, "activity", None)
if activity is None: if activity is None:
@@ -163,36 +163,9 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str:
return str(activity) return str(activity)
def _parse_datetime(value: Any) -> Optional[datetime]: def _fetch_issues_chunked(redmine: Redmine, issue_ids: list[int]) -> list[Issue]:
"""Parse a datetime from Redmine API response.
Accepts datetime objects, ISO strings (with or without timezone),
or None. Returns a timezone-aware datetime or None.
Naive datetimes are treated as UTC (#58).
"""
if value is None:
return None
if isinstance(value, datetime):
return _ensure_aware_utc(value)
if isinstance(value, str):
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return _ensure_aware_utc(dt)
except (ValueError, TypeError):
return None
return None
def _ensure_aware_utc(dt: datetime) -> datetime:
"""Возвращает aware datetime; naive трактуется как UTC (#58)."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = [] all_issues: list[Issue] = []
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE): for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE] chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
issue_list_str = ",".join(str(x) for x in chunk) issue_list_str = ",".join(str(x) for x in chunk)
@@ -203,7 +176,7 @@ def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]
return all_issues return all_issues
def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int: def _resolve_user_id(redmine: Redmine, user_arg: int | str) -> int:
"""Преобразует строковый идентификатор пользователя в числовой ID. """Преобразует строковый идентификатор пользователя в числовой ID.
Если аргумент — число, возвращает его как есть. Если аргумент — число, возвращает его как есть.
@@ -276,20 +249,14 @@ def _get_current_user_id(redmine: Redmine) -> int:
def fetch_issues_with_spent_time( def fetch_issues_with_spent_time(
from_date: str, from_date: str,
to_date: str, to_date: str,
user_id: Optional[Union[int, str]] = None, user_id: int | str | None = None,
by_activity: bool = False, by_activity: bool = False,
dedup_before: Optional[datetime] = None, ) -> list[tuple[Issue, float, dict[str, float] | None]] | None:
) -> 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 +271,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,41 +281,9 @@ 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]] = {}
issue_ids = set() issue_ids = set()
for entry in time_entries: for entry in time_entries:
if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"): if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
@@ -397,3 +334,66 @@ def fetch_issues_with_spent_time(
result.append((issue, total_hours, activity_breakdown)) result.append((issue, total_hours, activity_breakdown))
return result return result
def fetch_open_issues(
user_id: int | str | None = None,
) -> list[tuple[Issue, date | None]]:
"""
Fetch open issues assigned to the given user across all projects,
paired with the due date of each issue's fixed version.
If user_id is None, uses current user.
Returns list of (issue, due_date) tuples in API order; sorting is done
in the builder layer. Raises RedmineAPIError on API/auth/network failures.
"""
try:
redmine = _create_redmine()
target_user_id = (
_resolve_user_id(redmine, user_id)
if user_id is not None
else _get_current_user_id(redmine)
)
# Без limit: python-redmine автопагинацией вычитывает все страницы,
# как в time_entry.filter выше.
issues = list(
redmine.issue.filter(assigned_to_id=target_user_id, status_id="open")
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# fixed_version внутри задачи содержит только id/name, поэтому due_date
# приходится добирать отдельным запросом на каждую уникальную версию (#71).
# Отказ загрузки одной версии не валидует выгрузку: её задачи получат None.
due_by_version: dict[int, date | None] = {}
for issue in issues:
version_ref = getattr(issue, "fixed_version", None)
if version_ref is None:
continue
version_id = getattr(version_ref, "id", None)
if version_id is None or version_id in due_by_version:
continue
try:
version = redmine.version.get(version_id)
except Exception as exc: # noqa: BLE001 -- отказ загрузки одной версии не валидует выгрузку, деградируем к due_date=None
print(
f"⚠️ Could not load version {version_id}: {exc}. "
"Due date will be empty for its issues.",
file=sys.stderr,
)
due_by_version[version_id] = None
else:
due_by_version[version_id] = getattr(version, "due_date", None)
# Порядок задач сохраняем как в ответе API.
result: list[tuple[Issue, date | None]] = []
for issue in issues:
version_ref = getattr(issue, "fixed_version", None)
version_id = (
getattr(version_ref, "id", None) if version_ref is not None else None
)
due = due_by_version.get(version_id) if version_id is not None else None
result.append((issue, due))
return result

View File

@@ -2,9 +2,8 @@ import logging
import os import os
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone from datetime import date
from pathlib import Path from pathlib import Path
from typing import Dict, Union
import yaml import yaml
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -13,7 +12,7 @@ from .yaml_config import check_file_permissions, resolve_env_vars
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_REDMINE_VERIFY: Union[bool, str] = True DEFAULT_REDMINE_VERIFY: bool | str = True
FALSE_VALUES = {"0", "false", "no", "off"} FALSE_VALUES = {"0", "false", "no", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"} TRUE_VALUES = {"1", "true", "yes", "on"}
@@ -40,33 +39,22 @@ 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: 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"
report_no_time: bool = False report_no_time: bool = False
report_status_translation: Dict[str, str] = field(default_factory=dict) report_status_translation: dict[str, str] = field(default_factory=dict)
email: EmailConfig = field(default_factory=EmailConfig) email: EmailConfig = field(default_factory=EmailConfig)
@classmethod @classmethod
def from_yaml(cls, path: Union[str, Path]) -> "AppConfig": def from_yaml(cls, path: str | Path) -> "AppConfig":
"""Загружает настройки из YAML-файла. """Загружает настройки из YAML-файла.
Если файл не существует — возвращает конфиг со значениями по умолчанию. Если файл не существует — возвращает конфиг со значениями по умолчанию.
@@ -100,12 +88,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 +101,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)
@@ -145,7 +117,7 @@ class AppConfig:
return str(value) return str(value)
@staticmethod @staticmethod
def _resolve_str_dict(raw: dict, section: str, key: str) -> Dict[str, str]: def _resolve_str_dict(raw: dict, section: str, key: str) -> dict[str, str]:
"""Читает вложенный mapping строк из YAML-секции. """Читает вложенный mapping строк из YAML-секции.
Не-mapping значение логируется с warning и игнорируется. Не-mapping значение логируется с warning и игнорируется.
@@ -167,7 +139,7 @@ class AppConfig:
type(value).__name__, type(value).__name__,
) )
return {} return {}
result: Dict[str, str] = {} result: dict[str, str] = {}
for k, v in value.items(): for k, v in value.items():
if v is None or isinstance(v, (dict, list)): if v is None or isinstance(v, (dict, list)):
logger.warning( logger.warning(
@@ -195,7 +167,7 @@ class AppConfig:
return False return False
@classmethod @classmethod
def _resolve_verify(cls, raw: dict) -> Union[bool, str]: def _resolve_verify(cls, raw: dict) -> bool | str:
"""Семантика verify_ssl, единая с REDMINE_VERIFY из env: """Семантика verify_ssl, единая с REDMINE_VERIFY из env:
- true (bool или строка) → True — стандартная проверка TLS (requests); - true (bool или строка) → True — стандартная проверка TLS (requests);
@@ -341,7 +313,7 @@ class Config:
return os.getenv("REDMINE_PASSWORD", "").strip() return os.getenv("REDMINE_PASSWORD", "").strip()
@classmethod @classmethod
def get_redmine_verify(cls) -> Union[bool, str]: def get_redmine_verify(cls) -> bool | str:
value = os.getenv("REDMINE_VERIFY", "").strip() value = os.getenv("REDMINE_VERIFY", "").strip()
if value: if value:
normalized = value.lower() normalized = value.lower()
@@ -365,24 +337,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:
@@ -409,7 +363,7 @@ class Config:
return False return False
@classmethod @classmethod
def get_status_translation(cls) -> Dict[str, str]: def get_status_translation(cls) -> dict[str, str]:
"""Полный словарь перевода статусов: встроенный + переопределения из YAML. """Полный словарь перевода статусов: встроенный + переопределения из YAML.
Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется. Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется.
@@ -432,50 +386,34 @@ 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 start = min(start, today)
and (cls._app.period_precision or "date") == "datetime" return f"{start.isoformat()}--{today.isoformat()}"
and cls._app.period_last_used_to
):
last_to = datetime.fromisoformat(
cls._app.period_last_used_to.replace("Z", "+00:00")
)
if last_to.tzinfo is None:
last_to = last_to.replace(tzinfo=timezone.utc)
else:
last_to = last_to.astimezone(timezone.utc)
now_utc = datetime.now(timezone.utc)
fmt = "%Y-%m-%dT%H:%M:%S"
return f"{last_to.strftime(fmt)}--{now_utc.strftime(fmt)}"
if (
cls._app
and cls._app.period_dynamic
and cls._app.period_last_used_from
and cls._app.period_last_used_to
):
nf, nt = compute_next_period(
cls._app.period_last_used_from,
cls._app.period_last_used_to,
cls._app.period_precision or "date",
)
return f"{nf}--{nt}"
if cls._app and cls._app.period_default_from:
default_to = cls._app.period_default_to or today_str
return f"{cls._app.period_default_from}--{default_to}"
start = today.replace(day=1)
return f"{start.isoformat()}--{today_str}"
@classmethod @classmethod
def validate(cls) -> None: def validate(cls) -> None:
@@ -500,52 +438,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

@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, List from typing import Any
from ..types import ReportRow from ..types import ReportRow
@@ -16,19 +16,17 @@ class Formatter(ABC):
""" """
@abstractmethod @abstractmethod
def format(self, rows: List[ReportRow]) -> Any: def format(self, rows: list[ReportRow]) -> Any:
""" """
Форматирует список строк отчета в нужный формат. Форматирует список строк отчета в нужный формат.
Для текстовых форматтеров возвращает str. Для текстовых форматтеров возвращает str.
Для ODTFormatter возвращает объект OpenDocument. Для ODTFormatter возвращает объект OpenDocument.
""" """
pass
@abstractmethod @abstractmethod
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
""" """
Сохраняет отформатированный отчет в файл по указанному пути. Сохраняет отформатированный отчет в файл по указанному пути.
Для форматтеров, которые не поддерживают сохранение (например, консольные), Для форматтеров, которые не поддерживают сохранение (например, консольные),
бросает NotImplementedError. бросает NotImplementedError.
""" """
pass

View File

@@ -1,5 +1,3 @@
from typing import List
from tabulate import tabulate from tabulate import tabulate
from ..types import ReportRow from ..types import ReportRow
@@ -9,7 +7,7 @@ from .base import Formatter
class TableFormatter(Formatter): class TableFormatter(Formatter):
"""Форматтер для вывода красивой таблицы в консоль.""" """Форматтер для вывода красивой таблицы в консоль."""
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]] table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
for r in rows: for r in rows:
time_text = r["time_text"].replace("\n", " / ") time_text = r["time_text"].replace("\n", " / ")
@@ -24,7 +22,7 @@ class TableFormatter(Formatter):
) )
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid") return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
# Консольные форматтеры не умеют сохранять в файл напрямую. # Консольные форматтеры не умеют сохранять в файл напрямую.
# Это делается в CLI. # Это делается в CLI.
raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.") raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.")
@@ -33,7 +31,7 @@ class TableFormatter(Formatter):
class CompactFormatter(Formatter): class CompactFormatter(Formatter):
"""Форматтер для компактного вывода в консоль.""" """Форматтер для компактного вывода в консоль."""
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
lines = [] lines = []
for r in rows: for r in rows:
time_text = r["time_text"].replace("\n", " / ") time_text = r["time_text"].replace("\n", " / ")
@@ -43,7 +41,7 @@ class CompactFormatter(Formatter):
) )
return "\n".join(lines) return "\n".join(lines)
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
# Консольные форматтеры не умеют сохранять в файл напрямую. # Консольные форматтеры не умеют сохранять в файл напрямую.
# Это делается в CLI. # Это делается в CLI.
raise NotImplementedError("CompactFormatter не поддерживает сохранение в файл.") raise NotImplementedError("CompactFormatter не поддерживает сохранение в файл.")

View File

@@ -1,6 +1,5 @@
import csv import csv
import io import io
from typing import List
from ..types import ReportRow from ..types import ReportRow
from .base import Formatter from .base import Formatter
@@ -19,7 +18,7 @@ class CSVFormatter(Formatter):
super().__init__() super().__init__()
self.no_time = no_time self.no_time = no_time
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output, dialect="excel") writer = csv.writer(output, dialect="excel")
headers = ["Project", "Version", "Issue ID", "Subject", "Status"] headers = ["Project", "Version", "Issue ID", "Subject", "Status"]
@@ -40,7 +39,7 @@ class CSVFormatter(Formatter):
writer.writerow(data) writer.writerow(data)
return output.getvalue() return output.getvalue()
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
content = self.format(rows) content = self.format(rows)
with open(output_path, "w", encoding="utf-8-sig", newline="") as f: with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
f.write(content) f.write(content)

View File

@@ -1,5 +1,3 @@
from typing import Dict, Optional, Type
from .base import Formatter from .base import Formatter
from .console import CompactFormatter, TableFormatter from .console import CompactFormatter, TableFormatter
from .csv import CSVFormatter from .csv import CSVFormatter
@@ -11,7 +9,7 @@ from .xlsx import XLSXFormatter
# Словарь для сопоставления расширений файлов с классами форматтеров. # Словарь для сопоставления расширений файлов с классами форматтеров.
# ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как # ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
# odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension. # odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
FORMATTER_MAP: Dict[str, Type[Formatter]] = { FORMATTER_MAP: dict[str, type[Formatter]] = {
".csv": CSVFormatter, ".csv": CSVFormatter,
".json": JSONFormatter, ".json": JSONFormatter,
".md": MarkdownFormatter, ".md": MarkdownFormatter,
@@ -21,13 +19,13 @@ FORMATTER_MAP: Dict[str, Type[Formatter]] = {
# Словарь для сопоставления типа вывода (консоль) с классами форматтеров # Словарь для сопоставления типа вывода (консоль) с классами форматтеров
CONSOLE_FORMATTER_MAP: Dict[str, Type[Formatter]] = { CONSOLE_FORMATTER_MAP: dict[str, type[Formatter]] = {
"table": TableFormatter, "table": TableFormatter,
"compact": CompactFormatter, "compact": CompactFormatter,
} }
def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]: def get_formatter_by_extension(extension: str, **kwargs) -> Formatter | None:
""" """
Возвращает экземпляр форматтера по расширению файла. Возвращает экземпляр форматтера по расширению файла.
Ключевые аргументы (**kwargs) передаются в конструктор форматтера. Ключевые аргументы (**kwargs) передаются в конструктор форматтера.
@@ -51,7 +49,7 @@ def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]:
return None return None
def get_console_formatter(formatter_type: str) -> Optional[Formatter]: def get_console_formatter(formatter_type: str) -> Formatter | None:
""" """
Возвращает экземпляр консольного форматтера по его типу. Возвращает экземпляр консольного форматтера по его типу.
""" """

View File

@@ -1,5 +1,4 @@
from html import escape from html import escape
from typing import List
from ..report_builder import group_rows_by_project_and_version from ..report_builder import group_rows_by_project_and_version
from ..types import ReportRow from ..types import ReportRow
@@ -12,7 +11,7 @@ class HTMLFormatter(Formatter):
def __init__(self, **_kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
projects = group_rows_by_project_and_version(rows) projects = group_rows_by_project_and_version(rows)
lines = [ lines = [
@@ -79,7 +78,7 @@ class HTMLFormatter(Formatter):
lines.append("</html>") lines.append("</html>")
return "\n".join(lines) return "\n".join(lines)
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
content = self.format(rows) content = self.format(rows)
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)

View File

@@ -1,5 +1,4 @@
import json import json
from typing import List
from ..types import ReportRow from ..types import ReportRow
from .base import Formatter from .base import Formatter
@@ -11,7 +10,7 @@ class JSONFormatter(Formatter):
def __init__(self, **_kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
data = [] data = []
for r in rows: for r in rows:
item = { item = {
@@ -28,7 +27,7 @@ class JSONFormatter(Formatter):
data.append(item) data.append(item)
return json.dumps(data, ensure_ascii=False, indent=2) return json.dumps(data, ensure_ascii=False, indent=2)
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
content = self.format(rows) content = self.format(rows)
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)

View File

@@ -1,5 +1,3 @@
from typing import List
from ..types import ReportRow from ..types import ReportRow
from .base import Formatter from .base import Formatter
@@ -14,7 +12,7 @@ class MarkdownFormatter(Formatter):
def __init__(self, **_kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
lines = [ lines = [
"| Проект | Версия | Задача | Статус | Затрачено |", "| Проект | Версия | Задача | Статус | Затрачено |",
"|--------|--------|--------|--------|-----------|", "|--------|--------|--------|--------|-----------|",
@@ -30,7 +28,7 @@ class MarkdownFormatter(Formatter):
) )
return "\n".join(lines) return "\n".join(lines)
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
content = self.format(rows) content = self.format(rows)
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)

View File

@@ -1,5 +1,4 @@
from importlib import resources from importlib import resources
from typing import List
from odf.opendocument import OpenDocument, load from odf.opendocument import OpenDocument, load
from odf.style import Style, TableCellProperties, TableColumnProperties from odf.style import Style, TableCellProperties, TableColumnProperties
@@ -25,7 +24,7 @@ class ODTFormatter(Formatter):
self.from_date = from_date self.from_date = from_date
self.to_date = to_date self.to_date = to_date
def format(self, rows: List[ReportRow]) -> OpenDocument: def format(self, rows: list[ReportRow]) -> OpenDocument:
""" """
Форматирует данные в объект OpenDocument. Форматирует данные в объект OpenDocument.
""" """
@@ -172,7 +171,7 @@ class ODTFormatter(Formatter):
return doc return doc
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
""" """
Сохраняет сформированный документ в файл. Сохраняет сформированный документ в файл.
""" """

View File

@@ -0,0 +1,44 @@
from tabulate import tabulate
from ..types import TaskRow
class TaskTableFormatter:
"""Консольный форматтер таблицы открытых задач пользователя (режим --my-tasks).
Не входит в фабрику файловых форматтеров: таблица всегда печатается
в stdout, поэтому сохранение в файл не поддержано.
"""
def __init__(self, base_url: str) -> None:
self.base_url = base_url
def _hyperlink(self, issue_id: int) -> str:
# OSC 8-гиперссылка: номер задачи остаётся видимым терминалом без
# поддержки ссылок, а поддерживающие делают его кликабельным.
url = f"{self.base_url.rstrip('/')}/issues/{issue_id}"
return f"\x1b]8;;{url}\x1b\\{issue_id}\x1b]8;;\x1b\\"
def format(self, rows: list[TaskRow]) -> str:
table_rows = [["ID", "Тема", "Статус", "Приор.", "Версия", "Срок"]]
for row in rows:
due_text = row["due_text"]
if row["overdue"]:
due_text = f"{due_text} (!)"
table_rows.append(
[
self._hyperlink(row["issue_id"]),
row["subject"],
row["status_ru"],
row["priority"],
row["version_name"] or "-",
due_text or "-",
]
)
return tabulate(table_rows, headers="firstrow", tablefmt="simple")
def save(self, rows: list[TaskRow], output_path: str) -> None:
# Консольные форматтеры не умеют сохранять в файл напрямую.
raise NotImplementedError(
"TaskTableFormatter не поддерживает сохранение в файл."
)

View File

@@ -1,5 +1,3 @@
from typing import Dict, List, Tuple
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
@@ -36,7 +34,7 @@ class XLSXFormatter(Formatter):
super().__init__() super().__init__()
self.no_time = no_time self.no_time = no_time
def format(self, rows: List[ReportRow]) -> Workbook: def format(self, rows: list[ReportRow]) -> Workbook:
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = "Report" ws.title = "Report"
@@ -56,9 +54,9 @@ class XLSXFormatter(Formatter):
grouped = group_rows_by_project_and_version(rows) grouped = group_rows_by_project_and_version(rows)
current_row = 2 current_row = 2
project_ranges: List[Tuple[int, int]] = [] project_ranges: list[tuple[int, int]] = []
version_ranges: List[Tuple[int, int]] = [] version_ranges: list[tuple[int, int]] = []
project_totals: Dict[str, float] = {} project_totals: dict[str, float] = {}
for project, versions in grouped.items(): for project, versions in grouped.items():
project_start_row = current_row project_start_row = current_row
@@ -175,11 +173,11 @@ class XLSXFormatter(Formatter):
return wb return wb
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
wb = self.format(rows) wb = self.format(rows)
wb.save(output_path) wb.save(output_path)
def _style_header_row(self, ws: Worksheet, headers: List[str]) -> None: def _style_header_row(self, ws: Worksheet, headers: list[str]) -> None:
for col_idx, _ in enumerate(headers, start=1): for col_idx, _ in enumerate(headers, start=1):
cell = ws.cell(row=1, column=col_idx) cell = ws.cell(row=1, column=col_idx)
cell.font = Font(bold=True) cell.font = Font(bold=True)
@@ -193,9 +191,7 @@ class XLSXFormatter(Formatter):
for col_idx in range(1, 8): for col_idx in range(1, 8):
cell = ws.cell(row=row, column=col_idx) cell = ws.cell(row=row, column=col_idx)
cell.border = self._BORDER cell.border = self._BORDER
if col_idx in (1, 2): if col_idx in (1, 2) or col_idx == 4:
cell.alignment = Alignment(vertical="top", wrap_text=True)
elif col_idx == 4:
cell.alignment = Alignment(vertical="top", wrap_text=True) cell.alignment = Alignment(vertical="top", wrap_text=True)
else: else:
cell.alignment = Alignment(vertical="top") cell.alignment = Alignment(vertical="top")
@@ -210,7 +206,7 @@ class XLSXFormatter(Formatter):
def _apply_column_widths(self, ws: Worksheet) -> None: def _apply_column_widths(self, ws: Worksheet) -> None:
# Минимальные ширины по умолчанию # Минимальные ширины по умолчанию
widths: Dict[int, float] = { widths: dict[int, float] = {
1: 18.0, 1: 18.0,
2: 16.0, 2: 16.0,
3: 12.0, 3: 12.0,

View File

@@ -5,7 +5,6 @@ import smtplib
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from typing import Dict, List
from .client import RedmineAPIError from .client import RedmineAPIError
from .config import EmailConfig from .config import EmailConfig
@@ -13,7 +12,7 @@ from .types import ReportRow
SMTP_TIMEOUT = 30 SMTP_TIMEOUT = 30
MIME_TYPES: Dict[str, str] = { MIME_TYPES: dict[str, str] = {
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".odt": "application/vnd.oasis.opendocument.text", ".odt": "application/vnd.oasis.opendocument.text",
".csv": "text/csv", ".csv": "text/csv",
@@ -43,7 +42,7 @@ def _utf8_text_part(text: str, subtype: str = "plain") -> MIMEText:
return part return part
def _build_html_body(rows: List[ReportRow]) -> str: def _build_html_body(rows: list[ReportRow]) -> str:
"""Генерирует HTML-версию тела письма через HTMLFormatter.""" """Генерирует HTML-версию тела письма через HTMLFormatter."""
from .formatters.html import HTMLFormatter from .formatters.html import HTMLFormatter
@@ -56,7 +55,7 @@ def build_message(
file_path: str, file_path: str,
author: str, author: str,
period: str, period: str,
rows: List[ReportRow], rows: list[ReportRow],
) -> MIMEMultipart: ) -> MIMEMultipart:
"""Формирует MIME-письмо с подстановками, телами и вложением.""" """Формирует MIME-письмо с подстановками, телами и вложением."""
subject = email_config.subject.replace("{author}", author).replace( subject = email_config.subject.replace("{author}", author).replace(
@@ -108,7 +107,7 @@ def send_report(
file_path: str, file_path: str,
author: str, author: str,
period: str, period: str,
rows: List[ReportRow], rows: list[ReportRow],
) -> None: ) -> None:
"""Отправляет сгенерированный отчёт по email через SMTP. """Отправляет сгенерированный отчёт по email через SMTP.

View File

@@ -1,4 +1,4 @@
from typing import Dict, List, Optional, Tuple, cast from typing import cast
from redminelib.resources import Issue from redminelib.resources import Issue
@@ -22,7 +22,7 @@ STATUS_TRANSLATION = {
} }
def _format_activities(activities: Dict[str, float]) -> str: def _format_activities(activities: dict[str, float]) -> str:
"""Форматирует разбивку по активностям в многострочный текст.""" """Форматирует разбивку по активностям в многострочный текст."""
if not activities: if not activities:
return "" return ""
@@ -32,11 +32,11 @@ def _format_activities(activities: Dict[str, float]) -> str:
def build_grouped_report( def build_grouped_report(
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]], issue_hours: list[tuple[Issue, float, dict[str, float] | None]],
fill_time: bool = True, fill_time: bool = True,
by_activity: bool = False, by_activity: bool = False,
status_translation: Optional[Dict[str, str]] = None, status_translation: dict[str, str] | None = None,
) -> List[ReportRow]: ) -> list[ReportRow]:
""" """
Преобразует список задач с затраченным временем в плоский список строк отчёта, Преобразует список задач с затраченным временем в плоский список строк отчёта,
с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений). с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений).
@@ -59,12 +59,12 @@ def build_grouped_report(
status_translation if status_translation is not None else STATUS_TRANSLATION status_translation if status_translation is not None else STATUS_TRANSLATION
) )
rows: List[ReportRow] = [] rows: list[ReportRow] = []
prev_project: str = "" prev_project: str = ""
prev_version: str = "" prev_version: str = ""
for issue, hours, *rest in issue_hours: for issue, hours, *rest in issue_hours:
activities: Optional[Dict[str, float]] = rest[0] if rest else None activities: dict[str, float] | None = rest[0] if rest else None
project = str(issue.project) project = str(issue.project)
version = get_version(issue) version = get_version(issue)
status_en = str(issue.status) status_en = str(issue.status)
@@ -108,14 +108,14 @@ def build_grouped_report(
def calculate_summary( def calculate_summary(
rows: List[ReportRow], rows: list[ReportRow],
by_activity: bool = False, by_activity: bool = False,
) -> Dict[str, float]: ) -> dict[str, float]:
"""Возвращает сводку: общее время, время по проектам, версиям и активностям.""" """Возвращает сводку: общее время, время по проектам, версиям и активностям."""
total = 0.0 total = 0.0
by_project: Dict[str, float] = {} by_project: dict[str, float] = {}
by_project_version: Dict[str, float] = {} by_project_version: dict[str, float] = {}
by_activity_name: Dict[str, float] = {} by_activity_name: dict[str, float] = {}
for r in rows: for r in rows:
hours = r.get("hours", 0.0) hours = r.get("hours", 0.0)
@@ -130,7 +130,7 @@ def calculate_summary(
for name, act_hours in activities.items(): for name, act_hours in activities.items():
by_activity_name[name] = by_activity_name.get(name, 0.0) + act_hours by_activity_name[name] = by_activity_name.get(name, 0.0) + act_hours
result: Dict[str, float] = { result: dict[str, float] = {
"total": round(total, 2), "total": round(total, 2),
**{f"project:{k}": round(v, 2) for k, v in by_project.items()}, **{f"project:{k}": round(v, 2) for k, v in by_project.items()},
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()}, **{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
@@ -144,8 +144,8 @@ def calculate_summary(
def group_rows_by_project_and_version( def group_rows_by_project_and_version(
rows: List[ReportRow], rows: list[ReportRow],
) -> Dict[str, Dict[str, List[ReportRow]]]: ) -> dict[str, dict[str, list[ReportRow]]]:
""" """
Группирует плоский список строк отчёта в иерархию project → version → [rows]. Группирует плоский список строк отчёта в иерархию project → version → [rows].
@@ -153,7 +153,7 @@ def group_rows_by_project_and_version(
Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+). Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+).
Используется форматтерами HTML и ODT для объединения ячеек. Используется форматтерами HTML и ODT для объединения ячеек.
""" """
projects: Dict[str, Dict[str, List[ReportRow]]] = {} projects: dict[str, dict[str, list[ReportRow]]] = {}
for r in rows: for r in rows:
project = r["project"] project = r["project"]
version = r["version"] version = r["version"]

View File

@@ -0,0 +1,149 @@
from datetime import date
from redminelib.resources import Issue
from .types import TaskRow, TaskSummary
from .utils import get_version
# В этом Redmine ровно пять приоритетов P1..P5; меньший номер -- срочнее.
PRIORITY_RANK: dict[str, int] = {"P1": 1, "P2": 2, "P3": 3, "P4": 4, "P5": 5}
# Ранг для неизвестного имени приоритета: такие задачи уходят в самый конец.
LOWEST_PRIORITY_RANK = len(PRIORITY_RANK) + 1
def priority_rank(name: str) -> int:
"""Возвращает ранг приоритета; неизвестное имя получает низший ранг."""
return PRIORITY_RANK.get(name, LOWEST_PRIORITY_RANK)
def _issue_version_name(issue: Issue) -> str:
"""Имя версии задачи или пустая строка, если версия не назначена."""
return get_version(issue, default="")
# "Wishlist" -- постоянная бакет-версия без срока (личный бакет Wendy, факт
# из трекера): задачи в ней -- отложенные идеи, создающие мусорный фон в
# персональной выборке, поэтому режим --my-tasks исключает их целиком --
# из таблицы и из саммари. Сравнение имени точное, без нормализации регистра.
EXCLUDED_VERSION_NAMES: frozenset[str] = frozenset({"Wishlist"})
def filter_excluded_versions(
items: list[tuple[Issue, date | None]],
) -> list[tuple[Issue, date | None]]:
"""Убирает задачи версий-исключений из выборки --my-tasks.
Доступ к fixed_version.name getattr-безопасный: задача без версии или
с версией без имени остаётся в выборке. Даты в парах сохраняются как есть.
"""
kept: list[tuple[Issue, date | None]] = []
for item in items:
version = getattr(item[0], "fixed_version", None)
name = getattr(version, "name", None) if version is not None else None
if name and str(name) in EXCLUDED_VERSION_NAMES:
continue
kept.append(item)
return kept
def _sort_key(item: tuple[Issue, date | None]) -> tuple[int, bool, date, int]:
"""Ключ сортировки: приоритет, затем срок по возрастанию, затем id.
Задачи без срока уходят в конец своей приоритетной группы: флаг
(due is None) сортирует False раньше True, а date.max служит нейтральным
значением, чтобы сравнение дат осталось корректным.
"""
issue, due = item
rank = priority_rank(str(issue.priority.name))
return (rank, due is None, due if due is not None else date.max, issue.id)
def build_task_rows(
items: list[tuple[Issue, date | None]],
status_translation: dict[str, str],
today: date | None = None,
) -> list[TaskRow]:
"""Строит отсортированный список строк режима --my-tasks.
items -- пары (задача, срок версии или None). today параметризован
для тестируемости; по умолчанию берётся текущая дата.
"""
if today is None:
today = date.today()
rows: list[TaskRow] = []
for issue, due in sorted(items, key=_sort_key):
status_name = str(issue.status)
rows.append(
{
"issue_id": issue.id,
"subject": str(issue.subject),
"status_ru": status_translation.get(status_name, status_name),
"priority": str(issue.priority.name),
"version_name": _issue_version_name(issue),
"due_text": due.isoformat() if due is not None else "",
"overdue": due is not None and due < today,
}
)
return rows
def calculate_task_summary(
items: list[tuple[Issue, date | None]],
today: date | None = None,
) -> TaskSummary:
"""Считает саммари по списку пар (задача, срок версии или None)."""
if today is None:
today = date.today()
overdue = 0
no_version = 0
no_due = 0
spent_hours = 0.0
estimated_hours = 0.0
estimated_count = 0
for issue, due in items:
if due is not None and due < today:
overdue += 1
version = getattr(issue, "fixed_version", None)
if version is None:
no_version += 1
elif due is None:
no_due += 1
raw_spent = getattr(issue, "spent_hours", None)
if raw_spent is not None:
spent_hours += float(raw_spent)
raw_estimated = getattr(issue, "estimated_hours", None)
if raw_estimated is not None:
estimated_hours += float(raw_estimated)
estimated_count += 1
return {
"total": len(items),
"overdue": overdue,
"no_version": no_version,
"no_due": no_due,
"spent_hours": spent_hours,
"estimated_hours": estimated_hours,
"estimated_count": estimated_count,
}
def format_summary_line(s: TaskSummary) -> str:
"""Форматирует однострочное саммари; блок оценки выводится при наличии данных."""
line = (
f"Открытых: {s['total']} | просроченных: {s['overdue']}"
f" | без версии: {s['no_version']} | без срока: {s['no_due']}"
f" | потрачено: {s['spent_hours']:g}h"
)
if s["estimated_count"] > 0:
line += (
f" | оценка: {s['estimated_hours']:g}h"
f" (по {s['estimated_count']} из {s['total']})"
)
return line

View File

@@ -1,4 +1,4 @@
from typing import Dict, Optional, TypedDict from typing import TypedDict
class ReportRowBase(TypedDict): class ReportRowBase(TypedDict):
@@ -18,4 +18,28 @@ class ReportRowBase(TypedDict):
class ReportRow(ReportRowBase, total=False): class ReportRow(ReportRowBase, total=False):
"""Строка итогового отчёта с опциональной разбивкой по активностям.""" """Строка итогового отчёта с опциональной разбивкой по активностям."""
activities: Optional[Dict[str, float]] activities: dict[str, float] | None
class TaskRow(TypedDict):
"""Строка списка открытых задач пользователя (режим --my-tasks)."""
issue_id: int
subject: str
status_ru: str
priority: str
version_name: str
due_text: str
overdue: bool
class TaskSummary(TypedDict):
"""Саммари списка открытых задач пользователя."""
total: int
overdue: int
no_version: int
no_due: int
spent_hours: float
estimated_hours: float
estimated_count: int

View File

@@ -28,11 +28,11 @@ def get_month_name_from_range(from_date: str, to_date: str) -> str:
return months[end.month] return months[end.month]
def get_version(issue) -> str: def get_version(issue, default: str = "<N/A>") -> str:
"""Возвращает версию задачи или '<N/A>', если не задана.""" """Возвращает версию задачи или default, если не задана."""
version = getattr(issue, "fixed_version", None) version = getattr(issue, "fixed_version", None)
if version is None: if version is None:
return "<N/A>" return default
name = getattr(version, "name", None) name = getattr(version, "name", None)
return str(name) if name else str(version) return str(name) if name else str(version)

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

@@ -1,9 +1,11 @@
import os import os
from datetime import date
from unittest import mock from unittest import mock
import pytest import pytest
from redmine_reporter.cli import main, parse_date_range from redmine_reporter.cli import main, parse_date_range
from redmine_reporter.client import RedmineAPIError
VALID_ENV = { VALID_ENV = {
"REDMINE_URL": "https://red.eltex.loc", "REDMINE_URL": "https://red.eltex.loc",
@@ -30,10 +32,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 +54,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 +445,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 +454,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 +469,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 +682,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,36 +707,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 сохраняет from окна и to в naive UTC.""" """Конец окна 2026-09-30 -- period.from ровно 2026-09-30, не 2026-10-01."""
from datetime import datetime
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-09-10T09:00:00--2026-09-12T18:00:00", "2026-09-01--2026-09-30",
"--commit", "--commit",
"--output", "--output",
str(tmp_path / "report.xlsx"), str(tmp_path / "report.xlsx"),
@@ -744,40 +739,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 saved_from == "2026-09-10T09:00:00"
assert datetime.strptime(saved_to, "%Y-%m-%dT%H:%M:%S")
assert call_args.args[3] == "datetime"
assert call_args.args[4] is True
@mock.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_naive_utc_to( 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 сохраняет to как naive UTC (#70).""" """Окно до 2026-12-31 -- from_str равен 2026-12-31: timedelta-математики нет."""
from datetime import datetime
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-09-10T09:00:00--2026-09-12T18:00:00", "2026-12-01--2026-12-31",
"--commit", "--commit",
"--output", "--output",
str(tmp_path / "report.xlsx"), str(tmp_path / "report.xlsx"),
@@ -787,120 +771,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]
assert saved_from == "2026-09-10T09:00:00"
parsed = datetime.fromisoformat(saved_to)
assert parsed.tzinfo is None, f"{saved_to} must be naive UTC"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_datetime_persists_window_start_and_now(
self, mock_save, mock_fetch, tmp_path
):
"""--commit при datetime сохраняет начало окна и текущий naive UTC (#70)."""
from datetime import datetime as real_datetime
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
)
fake_datetime = mock.MagicMock()
fake_datetime.fromisoformat = real_datetime.fromisoformat
fake_datetime.strptime = real_datetime.strptime
fake_datetime.now.return_value = real_datetime(2026, 9, 15, 12, 0, 0)
with mock.patch("redmine_reporter.cli.datetime", fake_datetime):
with mock.patch(
"redmine_reporter.cli.get_formatter_by_extension"
) as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--commit",
"--date",
"2026-09-10T09:00:00--2026-09-12T18:00:00",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path),
"2026-09-10T09:00:00",
"2026-09-15T12:00:00",
"datetime",
True,
)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
"""Без записей --commit не пишет конфиг."""
mock_fetch.return_value = None
config_path = tmp_path / "config.yml"
config_path.write_text("period:\n precision: date\n dynamic: true\n")
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_dynamic_false_overwrites_defaults(
self, mock_save, mock_fetch, tmp_path
):
"""При dynamic=false --commit перезаписывает default_from/to."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": False}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path), "2026-06-01", "2026-06-30", "date", False
)
@mock.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")
@@ -918,7 +789,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}",
@@ -956,9 +827,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()
@@ -982,16 +851,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(
[ [
@@ -1005,6 +870,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
@@ -1203,8 +1126,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(
@@ -1221,7 +1143,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")
@@ -1231,6 +1153,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."""
@@ -1385,7 +1493,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"},
} }
) )
) )
@@ -1425,7 +1533,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"},
} }
) )
) )
@@ -1465,7 +1573,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"},
} }
) )
) )
@@ -1581,45 +1689,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: скользящее окно от last_used.to до now (#70).
Ожидаемое поведение после фикса #70: дефолтный диапазон начинается с
last_used.to и заканчивается текущим моментом, а не сдвинутым периодом.
"""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
"period:\n"
" precision: datetime\n"
" dynamic: true\n"
" last_used:\n"
" from: '2026-06-01T00:00:00'\n"
" to: '2026-06-30T23:59:59'\n"
)
real_dt = __import__("datetime").datetime
with mock.patch("redmine_reporter.config.datetime") as dt:
dt.fromisoformat = real_dt.fromisoformat
dt.now.return_value = real_dt(2026, 7, 15, 10, 0, 0)
code = main(["--config-path", str(config_path)])
assert code == 0
args, _ = mock_fetch.call_args
from_date, to_date = args[0], args[1]
assert from_date == "2026-06-30T23:59:59"
assert to_date == "2026-07-15T10:00:00"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# #55: sanitize Redmine exception output in CLI # #55: sanitize Redmine exception output in CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1930,3 +1999,192 @@ class TestSendConfirmation:
assert "bcc@example.com" in captured.out assert "bcc@example.com" in captured.out
assert output in captured.out assert output in captured.out
mock_input.assert_called_once_with("Отправить? [y/N]: ") mock_input.assert_called_once_with("Отправить? [y/N]: ")
# ---------------------------------------------------------------------------
# #71: --my-tasks tests
# ---------------------------------------------------------------------------
def _make_task_issue(issue_id, subject="Задача", status="New", priority="P3"):
"""Mock-issue с явно заданными атрибутами: MagicMock-автогенерация недопустима.
build_task_rows читает nested-атрибуты (priority.name); unset-атрибут
MagicMock был бы автосконструирован и str() дал бы нестабильный текст.
"""
issue = mock.MagicMock()
issue.id = issue_id
issue.subject = subject
issue.status = status
issue.priority.name = priority
issue.fixed_version = None
issue.spent_hours = 0.0
issue.estimated_hours = None
return issue
def _expected_link(issue_id):
"""Точная OSC 8-последовательность для ссылки на задачу в stdout."""
return (
f"\x1b]8;;https://red.eltex.loc/issues/{issue_id}\x1b\\{issue_id}\x1b]8;;\x1b\\"
)
class TestMyTasksFlag:
"""Tests for --my-tasks flag (#71)."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@pytest.mark.parametrize(
"flag_args",
[
["--date", "2026-01-01--2026-01-31"],
["--output", "/tmp/report.xlsx"],
["--compact"],
["--summary"],
["--by-activity"],
["--no-time"],
["--commit"],
["--send"],
],
)
def test_my_tasks_rejects_report_flags(self, flag_args, capsys):
"""--my-tasks несовместим с каждым отчётным флагом: выход 1 и сообщение."""
code = main(["--my-tasks"] + flag_args)
captured = capsys.readouterr()
assert code == 1
assert "--my-tasks cannot be used with report-generating flags" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_empty_list_exits_zero(self, mock_fetch, capsys):
"""Пустой список открытых задач -- выход 0 и сообщение в stderr."""
mock_fetch.return_value = []
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 0
assert "Открытых задач нет" in captured.err
assert captured.out == ""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_passes_user_id(self, mock_fetch):
"""--user-id передаётся в fetch_open_issues."""
mock_fetch.return_value = []
code = main(["--my-tasks", "--user-id", "42"])
assert code == 0
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "42"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_no_user_flag_passes_none(self, mock_fetch):
"""Без флагов пользователя в fetch_open_issues уходит user_id=None."""
mock_fetch.return_value = []
code = main(["--my-tasks"])
assert code == 0
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] is None
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_happy_path(self, mock_fetch, capsys):
"""Таблица с OSC 8-ссылкой и переведённым статусом в stdout; саммари в stderr."""
issue_overdue = _make_task_issue(42, subject="Починить баг")
issue_fresh = _make_task_issue(43, subject="Добавить фичу", status="Pending")
# Срок глубоко в прошлом: overdue не зависит от даты прогона тестов.
mock_fetch.return_value = [
(issue_fresh, None),
(issue_overdue, date(2000, 1, 1)),
]
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 0
# Ссылка на base_url из env теста с точной escape-последовательностью.
assert _expected_link(42) in captured.out
assert _expected_link(43) in captured.out
# Переведённые статусы попадают в таблицу.
assert "В работе" in captured.out
assert "Ожидание" in captured.out
assert "Починить баг" in captured.out
# Саммари -- в stderr, таблица -- не там.
assert "Открытых: 2" in captured.err
assert "просроченных: 1" in captured.err
assert "потрачено:" in captured.err
assert "Открытых:" not in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_wishlist_filtered_out_of_table_and_summary(
self, mock_fetch, capsys
):
"""Задача из Wishlist не попадает ни в таблицу, ни в саммари."""
wishlist = _make_task_issue(50, subject="Идея на потом")
wishlist.fixed_version = mock.MagicMock()
wishlist.fixed_version.name = "Wishlist"
normal = _make_task_issue(51, subject="Рабочая задача")
mock_fetch.return_value = [(wishlist, None), (normal, None)]
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 0
assert _expected_link(51) in captured.out
assert "Рабочая задача" in captured.out
assert _expected_link(50) not in captured.out
assert "Идея на потом" not in captured.out
assert "Открытых: 1" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_only_wishlist_reports_empty_and_exits_zero(
self, mock_fetch, capsys
):
"""Все задачи в Wishlist -- выборка пуста: 'Открытых задач нет', код 0."""
wishlist = _make_task_issue(60, subject="Ещё идея")
wishlist.fixed_version = mock.MagicMock()
wishlist.fixed_version.name = "Wishlist"
mock_fetch.return_value = [(wishlist, None)]
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 0
assert "Открытых задач нет" in captured.err
assert captured.out == ""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_api_error_exits_one(self, mock_fetch, capsys):
"""RedmineAPIError -- понятное сообщение и выход 1."""
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "Authentication failed" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_unexpected_error_exits_one(self, mock_fetch, capsys):
"""Непредвиденное исключение -- Unexpected error и выход 1."""
mock_fetch.side_effect = RuntimeError("boom")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "Unexpected error" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.TaskTableFormatter")
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_format_error_exits_one(
self, mock_fetch, mock_formatter_cls, capsys
):
"""Сбой рендера таблицы -- Formatting error и выход 1, без трейсбека."""
mock_fetch.return_value = [(_make_task_issue(7), None)]
mock_formatter_cls.return_value.format.side_effect = ValueError("boom")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "❌ Formatting error: boom" in captured.err

View File

@@ -1,9 +1,10 @@
import os import os
from datetime import date
from unittest import mock from unittest import mock
import pytest import pytest
from redmine_reporter.client import fetch_issues_with_spent_time from redmine_reporter.client import fetch_issues_with_spent_time, fetch_open_issues
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
PASSWORD_ENV = { PASSWORD_ENV = {
@@ -57,7 +58,7 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
assert result is not None assert result is not None
assert len(result) == 1 assert len(result) == 1
issue, total_hours, _activities = result[0] _issue, total_hours, _activities = result[0]
assert total_hours == 3.5 assert total_hours == 3.5
@@ -605,256 +606,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 +655,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 +761,245 @@ 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
# -- #71: fetch_open_issues --
def _make_open_issue(issue_id, version_id=None):
"""Создаёт mock открытой задачи; без version_id эмулирует отсутствие fixed_version.
Спека без fixed_version нужна, чтобы getattr(issue, "fixed_version", None)
вернул None, а не auto-атрибут MagicMock.
"""
if version_id is None:
issue = mock.MagicMock(spec=["id", "subject", "project"])
issue.id = issue_id
return issue
issue = mock.MagicMock()
issue.id = issue_id
issue.fixed_version.id = version_id
return issue
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_filters_by_user_and_open_status(mock_redmine_class):
"""issue.filter вызван ровно с assigned_to_id и status_id="open" (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=123, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_default_user_is_current(mock_redmine_class):
"""Без user_id резолвится текущий пользователь через user.get("current") (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=77)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.user.get.assert_called_once_with("current")
_, kwargs = mock_redmine.issue.filter.call_args
assert kwargs["assigned_to_id"] == 77
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_numeric_user_id_skips_user_lookup(mock_redmine_class):
"""Числовой user_id: user.get не вызывается, фильтр по переданному ID (#71)."""
mock_redmine = mock_redmine_class.return_value
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id=42)
mock_redmine.user.get.assert_not_called()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=42, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_resolves_login_to_id(mock_redmine_class):
"""Строковый логин разрешается в числовой ID как в fetch_issues_with_spent_time (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_user = mock.MagicMock()
mock_user.id = 7
mock_user.login = "ivanov"
mock_redmine.user.filter.return_value = [mock_user]
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id="ivanov")
login_filter_call = mock_redmine.user.filter.call_args_list[0]
assert login_filter_call.kwargs == {"login": "ivanov"}
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=7, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_fetches_each_unique_version_once(mock_redmine_class):
"""Две задачи с одной версией -> один version.get на версию (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2, version_id=5),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert mock_redmine.version.get.call_count == 2
version_ids = [call.args[0] for call in mock_redmine.version.get.call_args_list]
assert version_ids == [5, 6]
assert len(result) == 3
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_attaches_version_due_date(mock_redmine_class):
"""due_date из version.get попадает в кортеж задачи (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
assert result is not None
issue, due = result[0]
assert issue.id == 1
assert due == date(2026, 11, 1)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_issue_without_version_has_empty_due(mock_redmine_class):
"""Задача без fixed_version: version.get для неё не вызывается, due None (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
mock_redmine.version.get.assert_called_once_with(5)
assert result is not None
assert result[1][0].id == 2
assert result[1][1] is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_version_failure_warns_and_continues(mock_redmine_class, capsys):
"""Сбой version.get: warning в stderr, due None, общий результат не валится (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.side_effect = Exception("version boom")
result = fetch_open_issues()
assert result is not None
assert result[0][1] is None
captured = capsys.readouterr()
assert "Could not load version 5" in captured.err
assert "version boom" in captured.err
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_result_length_matches_issues(mock_redmine_class):
"""Результат по длине и порядку соответствует списку задач из API (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert result is not None
assert len(result) == 3
assert [issue.id for issue, _ in result] == [1, 2, 3]
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_wraps_api_errors(mock_redmine_class):
"""Сбой issue.filter оборачивается в RedmineAPIError с понятным сообщением (#71)."""
from redminelib.exceptions import AuthError
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.side_effect = AuthError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Authentication failed"):
fetch_open_issues()

View File

@@ -1,6 +1,8 @@
import contextlib
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import ClassVar
from unittest import mock from unittest import mock
import pytest import pytest
@@ -8,6 +10,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 +76,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()
@@ -99,25 +97,6 @@ def test_get_default_date_range_fallback():
assert result == f"{start.isoformat()}--{today.isoformat()}" assert result == f"{start.isoformat()}--{today.isoformat()}"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_default_date_range_datetime_dynamic_sliding_window():
app = AppConfig(
period_precision="datetime",
period_dynamic=True,
period_last_used_to="2026-09-10T15:00:00+00:00",
)
Config._app = app
try:
real_dt = __import__("datetime").datetime
with mock.patch("redmine_reporter.config.datetime") as dt:
dt.fromisoformat = real_dt.fromisoformat
dt.now.return_value = real_dt(2026, 9, 15, 10, 0, 0)
rng = Config.get_default_date_range()
finally:
Config._app = None
assert rng == "2026-09-10T15:00:00--2026-09-15T10:00:00"
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) -- # -- #56: дефолтный период — текущий месяц (детерминированные тесты) --
@@ -145,31 +124,96 @@ 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), 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), 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():
@@ -251,7 +295,10 @@ class TestConfigRequiresHttpsUrl:
class TestWarnWhenTlsVerificationDisabled: class TestWarnWhenTlsVerificationDisabled:
"""verify_ssl: false должен давать видимый warning в stderr (риск MITM).""" """verify_ssl: false должен давать видимый warning в stderr (риск MITM)."""
_ENV = {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"} _ENV: ClassVar[dict[str, str]] = {
"REDMINE_URL": "https://red.eltex.loc/",
"REDMINE_API_KEY": "token",
}
def _validate_env(self, verify: str): def _validate_env(self, verify: str):
return {**self._ENV, "REDMINE_VERIFY": verify} return {**self._ENV, "REDMINE_VERIFY": verify}
@@ -318,13 +365,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
@@ -363,12 +404,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"
@@ -391,7 +427,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 == []
@@ -404,7 +440,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")
@@ -552,14 +617,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)
@@ -589,205 +655,19 @@ 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 (
assert result == f"2026-04-01--{today}" mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True),
mock_today(date(2026, 7, 15)),
):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
class TestGetEmailConfig: class TestGetEmailConfig:

View File

@@ -1,6 +1,5 @@
import io import io
import json import json
from typing import List
from unittest import mock from unittest import mock
import pytest import pytest
@@ -25,7 +24,7 @@ def _make_empty_odt_bytes() -> bytes:
return buf.getvalue() return buf.getvalue()
def make_fake_report_rows() -> List[ReportRow]: def make_fake_report_rows() -> list[ReportRow]:
""" """
Генерирует фейковый отчёт с полным покрытием логики группировки: Генерирует фейковый отчёт с полным покрытием логики группировки:
- Проект A: версия v1.0 (2 задачи), версия v2.0 (1 задача) - Проект A: версия v1.0 (2 задачи), версия v2.0 (1 задача)

View File

@@ -65,7 +65,7 @@ class TestBuildMessage:
data = buf.getvalue() data = buf.getvalue()
assert b"Content-Transfer-Encoding: 8bit" in data assert b"Content-Transfer-Encoding: 8bit" in data
assert "Автор: Иванов".encode("utf-8") in data assert "Автор: Иванов".encode() in data
def test_plain_part_uses_8bit_utf8(self): def test_plain_part_uses_8bit_utf8(self):
"""Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть.""" """Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть."""
@@ -74,7 +74,7 @@ class TestBuildMessage:
) )
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", []) msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
plain = [p for p in msg.walk() if p.get_content_type() == "text/plain"][0] plain = next(p for p in msg.walk() if p.get_content_type() == "text/plain")
assert plain["Content-Transfer-Encoding"] == "8bit" assert plain["Content-Transfer-Encoding"] == "8bit"
assert ( assert (
plain.get_payload(decode=True).decode("utf-8") plain.get_payload(decode=True).decode("utf-8")
@@ -205,7 +205,7 @@ class TestBuildMessage:
] ]
msg = build_message(cfg, str(report), "A", "P", rows) msg = build_message(cfg, str(report), "A", "P", rows)
html_part = [p for p in msg.walk() if p.get_content_type() == "text/html"][0] html_part = next(p for p in msg.walk() if p.get_content_type() == "text/html")
payload = html_part.get_payload(decode=True).decode("utf-8") payload = html_part.get_payload(decode=True).decode("utf-8")
assert "Project X" in payload assert "Project X" in payload
assert "Important task" in payload assert "Important task" in payload
@@ -228,9 +228,9 @@ class TestBuildMessage:
] ]
msg = build_message(cfg, str(report), "A", "P", rows) msg = build_message(cfg, str(report), "A", "P", rows)
alternative = [ alternative = next(
p for p in msg.walk() if p.get_content_type() == "multipart/alternative" p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
][0] )
payloads = alternative.get_payload() payloads = alternative.get_payload()
assert payloads[0].get_content_type() == "text/plain" assert payloads[0].get_content_type() == "text/plain"
assert payloads[1].get_content_type() == "text/html" assert payloads[1].get_content_type() == "text/html"
@@ -273,9 +273,11 @@ class TestSendReport:
report.write_text("fake content") report.write_text("fake content")
cfg = _make_email_config() cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")): with (
with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"): mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")),
send_report(cfg, str(report), "A", "P", []) pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"),
):
send_report(cfg, str(report), "A", "P", [])
def test_send_auth_error(self, tmp_path): def test_send_auth_error(self, tmp_path):
report = tmp_path / "report.xlsx" report = tmp_path / "report.xlsx"
@@ -297,9 +299,11 @@ class TestSendReport:
report.write_text("fake content") report.write_text("fake content")
cfg = _make_email_config() cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=TimeoutError()): with (
with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"): mock.patch("smtplib.SMTP", side_effect=TimeoutError()),
send_report(cfg, str(report), "A", "P", []) pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"),
):
send_report(cfg, str(report), "A", "P", [])
def test_send_generic_smtp_error(self, tmp_path): def test_send_generic_smtp_error(self, tmp_path):
report = tmp_path / "report.xlsx" report = tmp_path / "report.xlsx"

350
tests/test_task_list.py Normal file
View File

@@ -0,0 +1,350 @@
"""Тесты доменного ядра режима --my-tasks (#71): строки, сортировка, саммари."""
from datetime import date
from unittest import mock
from redmine_reporter.task_list import (
EXCLUDED_VERSION_NAMES,
PRIORITY_RANK,
build_task_rows,
calculate_task_summary,
filter_excluded_versions,
format_summary_line,
priority_rank,
)
TODAY = date(2026, 10, 28)
def make_issue(
issue_id,
priority="P3",
status="New",
fixed_version=None,
subject="Задача",
due=None,
spent_hours=0.0,
estimated_hours=None,
):
"""Собирает mock-issue с nested-атрибутами priority.name / fixed_version.name.
fixed_version=None присваивается явно: unset-атрибут MagicMock был бы
автосконструирован, и getattr() вернул бы его вместо безопасного дефолта.
"""
issue = mock.MagicMock()
issue.id = issue_id
issue.subject = subject
issue.status = status
issue.priority.name = priority
if fixed_version is None:
issue.fixed_version = None
else:
issue.fixed_version.name = fixed_version
issue.spent_hours = spent_hours
# None присваивается явно, чтобы getattr() в реализации не наткнулся
# на автосконструированный MagicMock-атрибут.
issue.estimated_hours = estimated_hours
return issue
# -- Приоритеты --
def test_priority_rank_table():
assert PRIORITY_RANK == {"P1": 1, "P2": 2, "P3": 3, "P4": 4, "P5": 5}
def test_priority_rank_known():
assert priority_rank("P1") == 1
assert priority_rank("P5") == 5
def test_priority_rank_unknown_is_lowest():
"""Неизвестное имя приоритета получает ранг ниже P5."""
assert priority_rank("P9") == 6
assert priority_rank("") == 6
def test_rows_sorted_by_priority():
"""P1 раньше P2 раньше P4; неизвестный P9 -- после P5."""
items = [
(make_issue(4, priority="P4"), None),
(make_issue(9, priority="P9"), None),
(make_issue(2, priority="P2"), None),
(make_issue(5, priority="P5"), None),
(make_issue(1, priority="P1"), None),
]
rows = build_task_rows(items, {}, today=TODAY)
assert [r["issue_id"] for r in rows] == [1, 2, 4, 5, 9]
# -- Сортировка внутри приоритетной группы --
def test_rows_due_ascending_within_priority():
items = [
(make_issue(2, priority="P2"), date(2026, 3, 10)),
(make_issue(1, priority="P2"), date(2026, 1, 1)),
(make_issue(3, priority="P2"), date(2026, 2, 15)),
]
rows = build_task_rows(items, {}, today=TODAY)
assert [r["issue_id"] for r in rows] == [1, 3, 2]
def test_rows_without_due_go_last_and_tiebreak_by_id():
"""Задачи без срока -- в конец своей группы; между ними tie-break по id."""
items = [
(make_issue(25, fixed_version="v2"), None), # версия есть, срока нет
(make_issue(30, fixed_version="v1"), date(2026, 2, 1)),
(make_issue(12), None), # версии нет, срока нет
]
rows = build_task_rows(items, {}, today=TODAY)
assert [r["issue_id"] for r in rows] == [30, 12, 25]
# -- Фильтр версий-исключений (Wishlist) --
def test_excluded_version_names_constant():
"""Точный состав бакет-версий, исключаемых из --my-tasks."""
assert EXCLUDED_VERSION_NAMES == frozenset({"Wishlist"})
def test_filter_excludes_wishlist_issue():
"""Задача в версии Wishlist удаляется из выборки."""
items = [
(make_issue(1, fixed_version="Wishlist"), None),
(make_issue(2, fixed_version="v2.5.0"), date(2026, 11, 1)),
]
kept = filter_excluded_versions(items)
assert [issue.id for issue, _ in kept] == [2]
def test_filter_keeps_issue_without_version():
"""Задача без версии фильтром не трогается."""
items = [(make_issue(3), None)]
kept = filter_excluded_versions(items)
assert [issue.id for issue, _ in kept] == [3]
def test_filter_keeps_regular_version_without_due():
"""Обычная версия без срока -- не бакет, задача остаётся."""
items = [(make_issue(4, fixed_version="v3.0.0"), None)]
kept = filter_excluded_versions(items)
assert [issue.id for issue, _ in kept] == [4]
def test_filter_is_case_sensitive():
"""Сравнение имени версии точное: wishlist строчными -- НЕ исключение."""
items = [
(make_issue(5, fixed_version="wishlist"), None),
(make_issue(6, fixed_version="WISHLIST"), None),
(make_issue(7, fixed_version="Wishlist"), None),
]
kept = filter_excluded_versions(items)
assert [issue.id for issue, _ in kept] == [5, 6]
def test_filter_returns_only_excluded_when_all_match():
items = [(make_issue(8, fixed_version="Wishlist"), None)]
assert filter_excluded_versions(items) == []
def test_filter_preserves_due_pairing():
"""Фильтр не теряет парную к задаче дату срока."""
due = date(2026, 12, 1)
items = [(make_issue(9, fixed_version="v1"), due)]
((issue, kept_due),) = filter_excluded_versions(items)
assert issue.id == 9
assert kept_due is due
def test_filter_tolerates_version_without_name_attribute():
"""fixed_version без атрибута name -- задача остаётся (getattr-безопасность)."""
issue = make_issue(10)
issue.fixed_version = mock.MagicMock(spec=[])
items = [(issue, None)]
kept = filter_excluded_versions(items)
assert [i.id for i, _ in kept] == [10]
# -- Просроченность --
def test_overdue_yesterday():
rows = build_task_rows([(make_issue(1), date(2026, 10, 27))], {}, today=TODAY)
assert rows[0]["overdue"] is True
def test_not_overdue_today():
"""Срок сегодня -- НЕ просрочен (строгое сравнение)."""
rows = build_task_rows([(make_issue(1), date(2026, 10, 28))], {}, today=TODAY)
assert rows[0]["overdue"] is False
def test_not_overdue_tomorrow():
rows = build_task_rows([(make_issue(1), date(2026, 10, 29))], {}, today=TODAY)
assert rows[0]["overdue"] is False
def test_not_overdue_without_due():
rows = build_task_rows([(make_issue(1), None)], {}, today=TODAY)
assert rows[0]["overdue"] is False
# -- Поля строки --
def test_row_fields():
issue = make_issue(
42,
priority="P1",
status="New",
fixed_version="v2.5.0",
subject="Починить баг",
)
rows = build_task_rows([(issue, date(2026, 10, 27))], {}, today=TODAY)
row = rows[0]
assert row["issue_id"] == 42
assert row["subject"] == "Починить баг"
assert row["priority"] == "P1"
assert row["version_name"] == "v2.5.0"
assert row["due_text"] == "2026-10-27"
assert row["overdue"] is True
def test_row_empty_version_and_due_text():
"""Без версии и без срока в строке пустые строки, а не заглушки."""
rows = build_task_rows([(make_issue(7), None)], {}, today=TODAY)
assert rows[0]["version_name"] == ""
assert rows[0]["due_text"] == ""
# -- Перевод статусов --
def test_status_translated_by_dict():
issue = make_issue(1, status="New")
rows = build_task_rows([(issue, None)], {"New": "Новая"}, today=TODAY)
assert rows[0]["status_ru"] == "Новая"
def test_status_unknown_passthrough():
issue = make_issue(1, status="SomeNewStatus")
rows = build_task_rows([(issue, None)], {"New": "Новая"}, today=TODAY)
assert rows[0]["status_ru"] == "SomeNewStatus"
# -- Саммари --
def test_summary_mixed_items():
"""Смесь: без версии / версия без срока / просроченная / с оценками и без."""
items = [
# без версии, без срока, без оценки
(make_issue(1, spent_hours=1.5), None),
# версия есть, срока нет, оценка есть
(make_issue(2, fixed_version="v1", spent_hours=2.0, estimated_hours=4.0), None),
# просроченная, оценка есть
(
make_issue(3, fixed_version="v2", spent_hours=0.5, estimated_hours=3.0),
date(2026, 10, 27),
),
# не просроченная, оценка None
(
make_issue(4, fixed_version="v3", spent_hours=0.0),
date(2026, 10, 29),
),
]
summary = calculate_task_summary(items, today=TODAY)
assert summary["total"] == 4
assert summary["overdue"] == 1
assert summary["no_version"] == 1
assert summary["no_due"] == 1
assert summary["spent_hours"] == 4.0
assert summary["estimated_hours"] == 7.0
assert summary["estimated_count"] == 2
def test_summary_empty():
summary = calculate_task_summary([], today=TODAY)
assert summary == {
"total": 0,
"overdue": 0,
"no_version": 0,
"no_due": 0,
"spent_hours": 0.0,
"estimated_hours": 0.0,
"estimated_count": 0,
}
def test_summary_missing_attributes_default_to_zero():
"""У issue без атрибутов spent_hours/estimated_hours безопасные нули."""
issue = mock.MagicMock(spec=["id", "subject", "status", "priority"])
issue.id = 5
issue.status = "New"
issue.priority.name = "P3"
summary = calculate_task_summary([(issue, None)], today=TODAY)
assert summary["spent_hours"] == 0.0
assert summary["estimated_hours"] == 0.0
assert summary["estimated_count"] == 0
assert summary["no_version"] == 1
assert summary["no_due"] == 0
# -- Строка саммари --
def test_format_summary_line_with_estimates():
summary = {
"total": 4,
"overdue": 1,
"no_version": 1,
"no_due": 1,
"spent_hours": 4.0,
"estimated_hours": 7.0,
"estimated_count": 2,
}
line = format_summary_line(summary)
assert line == (
"Открытых: 4 | просроченных: 1 | без версии: 1 | без срока: 1 "
"| потрачено: 4h | оценка: 7h (по 2 из 4)"
)
def test_format_summary_line_without_estimates():
summary = {
"total": 3,
"overdue": 0,
"no_version": 2,
"no_due": 1,
"spent_hours": 2.5,
"estimated_hours": 0.0,
"estimated_count": 0,
}
line = format_summary_line(summary)
assert "оценка" not in line
assert line == (
"Открытых: 3 | просроченных: 0 | без версии: 2 | без срока: 1 | потрачено: 2.5h"
)
def test_format_summary_line_fractional_hours_use_g_format():
"""Часы форматируются :g -- без хвостовых нулей."""
summary = {
"total": 1,
"overdue": 0,
"no_version": 0,
"no_due": 0,
"spent_hours": 21.5,
"estimated_hours": 7.25,
"estimated_count": 1,
}
line = format_summary_line(summary)
assert "21.5h" in line
assert "7.25h" in line
assert "21.500000" not in line

161
tests/test_task_table.py Normal file
View File

@@ -0,0 +1,161 @@
"""Тесты консольного форматтера таблицы режима --my-tasks (#71)."""
import re
from typing import cast
import pytest
from redmine_reporter.formatters.task_table import TaskTableFormatter
from redmine_reporter.types import TaskRow
BASE_URL = "https://red.eltex.loc"
# OSC 8-гиперссылка: esc-последовательность до текста, текст, закрытие ссылки.
OSC8_RE = re.compile(r"\x1b\]8;;[^\x1b]*\x1b\\")
def make_row(**overrides) -> TaskRow:
row: TaskRow = {
"issue_id": 42,
"subject": "Починить баг",
"status_ru": "Новая",
"priority": "P2",
"version_name": "v2.5.0",
"due_text": "2026-10-15",
"overdue": False,
}
# **-распаковка dict[str, Any] в TypedDict-литерале mypy не поддерживает,
# а kwargs-словарь типизируется только как dict[str, Any], поэтому приведение.
row.update(cast(TaskRow, overrides))
return row
def expected_hyperlink(issue_id, base_url=BASE_URL):
return f"\x1b]8;;{base_url}/issues/{issue_id}\x1b\\{issue_id}\x1b]8;;\x1b\\"
# -- OSC 8-гиперссылка на ID --
def test_id_wrapped_in_exact_osc8_sequence():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
assert expected_hyperlink(42) in output
def test_base_url_trailing_slash_not_duplicated():
"""base_url с завершающим слэшем не даёт двойного слэша перед /issues."""
output = TaskTableFormatter(base_url=BASE_URL + "/").format([make_row()])
assert expected_hyperlink(42) in output
assert "//issues/" not in output
def test_hyperlink_text_is_issue_number_only():
"""Текст ссылки -- только номер, тема не попадает внутрь OSC 8."""
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(issue_id=7, subject="Тема задачи")]
)
assert expected_hyperlink(7) in output
assert "Тема задачи" in output
start = output.index("Тема задачи")
assert "\x1b" not in output[:start].replace(expected_hyperlink(7), "")
def test_hyperlink_uses_row_issue_id():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(issue_id=1234)])
assert expected_hyperlink(1234) in output
# -- Заполнение пустых полей --
def test_empty_version_rendered_as_dash():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(version_name="")])
assert "-" in output
def test_empty_due_rendered_as_dash():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(due_text="")])
assert "-" in output
def test_nonempty_fields_not_replaced_by_dash():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
assert "v2.5.0" in output
assert "2026-10-15" in output
# -- Маркер просрочки --
def test_overdue_due_appends_marker():
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(due_text="2026-10-15", overdue=True)]
)
assert "2026-10-15 (!)" in output
def test_not_overdue_due_without_marker():
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(due_text="2026-10-15", overdue=False)]
)
assert "2026-10-15" in output
assert "(!)" not in output
# -- Заголовки и содержимое --
def test_column_headers_present():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
for header in ("ID", "Тема", "Статус", "Приор.", "Версия", "Срок"):
assert header in output
def test_status_passed_through_as_is():
"""status_ru из TaskRow попадает в таблицу без изменений."""
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(status_ru="В работе")]
)
assert "В работе" in output
def test_subject_and_priority_present():
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(subject="Обновить драйвер", priority="P1")]
)
assert "Обновить драйвер" in output
assert "P1" in output
def test_multiple_rows_rendered():
rows = [make_row(issue_id=1), make_row(issue_id=2)]
output = TaskTableFormatter(base_url=BASE_URL).format(rows)
assert expected_hyperlink(1) in output
assert expected_hyperlink(2) in output
def test_empty_rows_renders_headers_only():
output = TaskTableFormatter(base_url=BASE_URL).format([])
assert "ID" in output
assert expected_hyperlink(42) not in output
# -- ESC только внутри ссылок --
def test_esc_only_inside_hyperlinks():
"""После вырезания OSC 8-ссылок в строке не остаётся ESC-символов."""
rows = [
make_row(issue_id=1),
make_row(issue_id=2, version_name=""),
make_row(issue_id=3, due_text="", overdue=True),
]
output = TaskTableFormatter(base_url=BASE_URL).format(rows)
cleaned = OSC8_RE.sub("", output)
assert "\x1b" not in cleaned
def test_save_raises_not_implemented():
formatter = TaskTableFormatter(base_url=BASE_URL)
with pytest.raises(NotImplementedError):
formatter.save([], "unused.xlsx")

View File

@@ -110,3 +110,21 @@ def test_get_version_falls_back_to_str_when_no_name():
fixed_version = MockVersionNoName() fixed_version = MockVersionNoName()
assert get_version(MockIssue()) == "fallback-id" assert get_version(MockIssue()) == "fallback-id"
def test_get_version_custom_default():
"""Явный default заменяет <N/A>, когда версия не назначена."""
class MockIssue:
pass
assert get_version(MockIssue(), default="") == ""
def test_get_version_custom_default_not_used_when_version_present():
"""При наличии версии default не применяется."""
class MockIssue:
fixed_version = "v1.0"
assert get_version(MockIssue(), default="") == "v1.0"

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"