Compare commits
7 Commits
v1.12.0
...
feature/71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375ea13a1f | ||
|
|
f00c6705ba | ||
|
|
5d1cf4ab74 | ||
|
|
3ce4f408ed | ||
|
|
27d21ef639 | ||
|
|
db069dfb4b | ||
|
|
4a9a21624d |
12
README.md
12
README.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|||||||
173
docs/CONFIG.md
173
docs/CONFIG.md
@@ -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 не заданы — используются кодовые дефолты (текущий месяц
|
||||||
|
|||||||
@@ -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-ключ в текстах ошибок маскируется (`***`).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "redmine-reporter"
|
name = "redmine-reporter"
|
||||||
version = "1.12.0"
|
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"]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.12.0"
|
__version__ = "2.1.0"
|
||||||
|
|||||||
@@ -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,36 +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":
|
# Начало следующего окна совпадает с концом текущего: граничный день
|
||||||
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
|
# перечитывается. Дубли записей дня коммита осознанны, зато поздние
|
||||||
# метки aware cutoff для дедупликации.
|
# записи, добавленные в тот же день после отправки, не теряются.
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
next_from = to_date
|
||||||
from_str = now
|
|
||||||
to_str = now
|
|
||||||
else:
|
|
||||||
from_str = from_date
|
|
||||||
to_str = to_date
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
save_period_to_config(
|
save_period_to_config(args.config_path, from_str=next_from)
|
||||||
args.config_path, from_str, to_str, precision, dynamic
|
except Exception as e: # 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, timedelta
|
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,33 +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_last_used_from
|
return f"{start.isoformat()}--{today.isoformat()}"
|
||||||
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:
|
||||||
@@ -483,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()
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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 не поддерживает сохранение в файл.")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
Возвращает экземпляр консольного форматтера по его типу.
|
Возвращает экземпляр консольного форматтера по его типу.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
Сохраняет сформированный документ в файл.
|
Сохраняет сформированный документ в файл.
|
||||||
"""
|
"""
|
||||||
|
|||||||
44
redmine_reporter/formatters/task_table.py
Normal file
44
redmine_reporter/formatters/task_table.py
Normal 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 не поддерживает сохранение в файл."
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
149
redmine_reporter/task_list.py
Normal file
149
redmine_reporter/task_list.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,34 +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 сохраняет timestamps."""
|
"""Конец окна 2026-09-30 -- period.from ровно 2026-09-30, не 2026-10-01."""
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(yaml.dump({"period": {"from": "2026-09-01"}}))
|
||||||
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
|
|
||||||
)
|
|
||||||
|
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
mock_formatter = mock.MagicMock()
|
mock_get.return_value = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
|
||||||
code = main(
|
code = main(
|
||||||
[
|
[
|
||||||
"--date",
|
"--date",
|
||||||
"2026-06-30--2026-06-30",
|
"2026-09-01--2026-09-30",
|
||||||
"--commit",
|
"--commit",
|
||||||
"--output",
|
"--output",
|
||||||
str(tmp_path / "report.xlsx"),
|
str(tmp_path / "report.xlsx"),
|
||||||
@@ -742,38 +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 "T" in saved_from
|
|
||||||
assert "T" in saved_to
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
def test_commit_with_precision_datetime_saves_aware_utc(
|
def test_commit_period_from_equals_window_end_year_boundary(
|
||||||
self, mock_save, mock_fetch, tmp_path
|
self, mock_save, mock_fetch, tmp_path
|
||||||
):
|
):
|
||||||
"""При precision=datetime --commit сохраняет last_used как aware UTC (#58)."""
|
"""Окно до 2026-12-31 -- from_str равен 2026-12-31: timedelta-математики нет."""
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(yaml.dump({"period": {"from": "2026-12-01"}}))
|
||||||
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
|
|
||||||
)
|
|
||||||
|
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
mock_formatter = mock.MagicMock()
|
mock_get.return_value = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
|
||||||
code = main(
|
code = main(
|
||||||
[
|
[
|
||||||
"--date",
|
"--date",
|
||||||
"2026-06-30--2026-06-30",
|
"2026-12-01--2026-12-31",
|
||||||
"--commit",
|
"--commit",
|
||||||
"--output",
|
"--output",
|
||||||
str(tmp_path / "report.xlsx"),
|
str(tmp_path / "report.xlsx"),
|
||||||
@@ -783,70 +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]
|
|
||||||
for saved in (saved_from, saved_to):
|
|
||||||
parsed = datetime.fromisoformat(saved)
|
|
||||||
assert parsed.tzinfo is not None, f"{saved} must be timezone-aware"
|
|
||||||
assert parsed.utcoffset() == timezone.utc.utcoffset(None)
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
|
||||||
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
|
|
||||||
"""Без записей --commit не пишет конфиг."""
|
|
||||||
mock_fetch.return_value = None
|
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
|
||||||
config_path.write_text("period:\n precision: date\n dynamic: true\n")
|
|
||||||
|
|
||||||
code = main(
|
|
||||||
[
|
|
||||||
"--date",
|
|
||||||
"2026-01-01--2026-01-31",
|
|
||||||
"--commit",
|
|
||||||
"--config-path",
|
|
||||||
str(config_path),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
assert code == 0
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
|
||||||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
|
||||||
def test_commit_dynamic_false_overwrites_defaults(
|
|
||||||
self, mock_save, mock_fetch, tmp_path
|
|
||||||
):
|
|
||||||
"""При dynamic=false --commit перезаписывает default_from/to."""
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
issue = _MockIssue()
|
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
|
||||||
config_path.write_text(
|
|
||||||
yaml.dump({"period": {"precision": "date", "dynamic": False}})
|
|
||||||
)
|
|
||||||
|
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
|
||||||
mock_formatter = mock.MagicMock()
|
|
||||||
mock_get.return_value = mock_formatter
|
|
||||||
code = main(
|
|
||||||
[
|
|
||||||
"--date",
|
|
||||||
"2026-06-01--2026-06-30",
|
|
||||||
"--commit",
|
|
||||||
"--output",
|
|
||||||
str(tmp_path / "report.xlsx"),
|
|
||||||
"--config-path",
|
|
||||||
str(config_path),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
assert code == 0
|
|
||||||
|
|
||||||
mock_save.assert_called_once_with(
|
|
||||||
str(config_path), "2026-06-01", "2026-06-30", "date", False
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@@ -864,7 +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}",
|
||||||
@@ -902,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()
|
||||||
@@ -928,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(
|
||||||
[
|
[
|
||||||
@@ -951,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
|
||||||
@@ -1149,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(
|
||||||
@@ -1167,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")
|
||||||
@@ -1177,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."""
|
||||||
@@ -1331,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"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1371,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"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1411,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"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1527,43 +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: datetime-диапазон из last_used принимается.
|
|
||||||
|
|
||||||
Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон
|
|
||||||
с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used,
|
|
||||||
отчёт строится без ошибки.
|
|
||||||
"""
|
|
||||||
issue = _MockIssue()
|
|
||||||
mock_fetch.return_value = [(issue, 1.0, None)]
|
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
|
||||||
config_path.write_text(
|
|
||||||
"period:\n"
|
|
||||||
" precision: datetime\n"
|
|
||||||
" dynamic: true\n"
|
|
||||||
" last_used:\n"
|
|
||||||
" from: '2026-06-01T00:00:00'\n"
|
|
||||||
" to: '2026-06-30T23:59:59'\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
code = main(["--config-path", str(config_path)])
|
|
||||||
|
|
||||||
assert code == 0
|
|
||||||
args, _ = mock_fetch.call_args
|
|
||||||
from_date, to_date = args[0], args[1]
|
|
||||||
# Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59
|
|
||||||
assert from_date == "2026-07-01T00:00:00"
|
|
||||||
assert to_date == "2026-07-30T23:59:59"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# #55: sanitize Redmine exception output in CLI
|
# #55: sanitize Redmine exception output in CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1874,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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -126,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():
|
||||||
@@ -232,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}
|
||||||
@@ -299,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
|
||||||
@@ -344,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"
|
||||||
@@ -372,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 == []
|
||||||
|
|
||||||
@@ -385,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")
|
||||||
@@ -533,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)
|
||||||
@@ -570,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:
|
||||||
|
|||||||
@@ -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 задача)
|
||||||
|
|||||||
@@ -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
350
tests/test_task_list.py
Normal 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
161
tests/test_task_table.py
Normal 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")
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user