9 Commits

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

Also migrates the codebase to ruff 0.17.3 defaults (PEP 585/604
annotations, targeted noqa for intentional blind excepts and CLI root
logging). Behavior-neutral: 408 tests pass with -W error.
2026-10-28 15:26:41 +07:00
Кокос Артем Николаевич
3ce4f408ed chore: bump version to 2.0.0
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
2026-09-30 12:37:00 +07:00
Кокос Артем Николаевич
27d21ef639 refactor: период через period.from вместо last_used; дедуп удалён
Удалены last_used, precision, dynamic и дедупликация по created_on/updated_on. Период задаётся одним полем period.from (env FROM_DATE), окно -- от этой даты до сегодня. После успешного --commit/--send начало окна сдвигается на конец отчёта; datetime-границы --date приводятся к полным дням. Схема конфига ломающая: старые ключи игнорируются, миграция описана в документации.
2026-09-30 12:37:00 +07:00
Кокос Артем Николаевич
db069dfb4b chore: bump version to 1.12.1
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
2026-09-15 15:43:11 +07:00
Кокос Артем Николаевич
4a9a21624d fix: sliding period window for datetime precision
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
After --commit with precision=datetime, last_used held from==to==now,
so the next default period degenerated to a single second and showed
no new time entries. Now --commit stores the real report window start
and get_default_date_range() returns last_used.to..now for
datetime+dynamic mode.

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

Version bump: 1.11.1 → 1.12.0
2026-07-26 16:15:10 +07:00
Кокос Артем Николаевич
3accb1212c fix: use OS trust store for TLS verification via truststore
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
Regression from #62: verify_ssl true used to resolve to the system CA
bundle path, so corporate CAs installed in the OS worked; after the
unification true became requests' default (certifi), breaking setups
with a corporate CA in the system store.

Now verify_ssl true injects truststore, so requests verifies against
the OS trust store on any platform. verify_ssl false / custom CA path
behavior is unchanged. Tests mock truststore via an autouse fixture to
keep the pytest process free of global ssl mutation.

Refs #62
2026-07-17 18:39:53 +07:00
33 changed files with 2516 additions and 1408 deletions

View File

@@ -19,9 +19,10 @@ CLI-инструмент для генерации отчётов по зада
- Группировка задач по проекту и версии, перевод статусов на русский язык.
- Вывод в консоль (таблица или компактный вид) и экспорт в ODT, CSV, Markdown, HTML, JSON, XLSX.
- Разбивка времени по типам активности (`--by-activity`), сводка (`--summary`), скрытие времени (`--no-time`).
- Гибкий выбор периода: `--date`, переменные окружения, YAML-конфиг, по умолчанию — текущий месяц.
- `--commit`: сохранение отчёта в файл и фиксация периода в конфиге для следующего запуска.
- `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо).
- Гибкий выбор периода: `--date`, env `FROM_DATE`, YAML `period.from`, по умолчанию — текущий месяц.
- `--commit`: сохранение отчёта в файл и авто-сдвиг начала периода в конфиге для следующего запуска.
- `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо) с авто-сдвигом начала периода при успешной отправке.
- `--my-tasks`: список назначенных вам открытых задач -- таблица с сортировкой по срочности (приоритет, срок версии) и сводкой; см. [руководство](docs/USER_GUIDE.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 --commit
# Список ваших открытых задач (сортировка по срочности)
redmine-reporter --my-tasks
```
## Документация
@@ -96,7 +100,7 @@ CI — Gitea Actions (`.gitea/workflows/checks.yaml`): все шесть про
## Безопасность
- `REDMINE_URL` обязан использовать HTTPS: валидация отклоняет остальное, API-ключ передаётся в заголовках.
- `verify_ssl` / `REDMINE_VERIFY`: `true` (по умолчанию), `false` (предупреждение о MITM-риске при старте) или путь к CA-bundle.
- `verify_ssl` / `REDMINE_VERIFY`: `true` (по умолчанию — проверка по системному хранилищу CA операционной системы через truststore, корпоративные CA из ОС работают), `false` (предупреждение о MITM-риске при старте) или путь к CA-bundle.
- Конфиг создаётся с правами `0600`, директория — `0700`; при более широких правах выводится предупреждение.
- Секреты храните через `${VAR}` в YAML или в переменных окружения, не в открытом виде.
- Инструмент только читает данные из Redmine и ничего в нём не изменяет.

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "redmine-reporter"
version = "1.11.0"
version = "2.1.0"
description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
@@ -25,6 +25,7 @@ dependencies = [
"openpyxl>=3.1.0",
"pyyaml>=6.0",
"requests>=2.31",
"truststore>=0.10",
"urllib3>=1.26",
]
@@ -61,3 +62,6 @@ ignore_missing_imports = true
[tool.isort]
profile = "black"
[tool.ruff.lint.isort]
known-first-party = ["redmine_reporter"]

View File

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

View File

@@ -3,19 +3,25 @@ import logging
import os
import re
import sys
from datetime import datetime, timezone
from datetime import datetime
from pathlib import Path
from typing import List, Optional
import yaml
from dotenv import find_dotenv, load_dotenv
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 .formatters.factory import get_console_formatter, get_formatter_by_extension
from .formatters.task_table import TaskTableFormatter
from .mailer import send_report
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
# Маскируем API-ключ Redmine в тексте ошибок (#55):
@@ -39,35 +45,38 @@ def _sanitize_error_text(text: str) -> str:
return text
def _parse_boundary(value: str) -> datetime:
"""Разбирает одну границу диапазона как дату или datetime без timezone."""
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
fmt = "%Y-%m-%d"
elif re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", value):
fmt = "%Y-%m-%dT%H:%M:%S"
else:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try:
return datetime.strptime(value, fmt)
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
def parse_date_range(date_arg: str) -> tuple[str, str]:
"""Возвращает границы диапазона в виде дат YYYY-MM-DD.
Каждая граница может быть датой или datetime; время отбрасывается
(границы окна -- целые дни), поэтому смешение форматов допустимо.
"""
if "--" not in date_arg:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
parts = date_arg.split("--", 1)
from_date, to_date = parts[0].strip(), parts[1].strip()
date_pattern = r"\d{4}-\d{2}-\d{2}"
datetime_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
if re.fullmatch(date_pattern, from_date) and re.fullmatch(date_pattern, to_date):
fmt = "%Y-%m-%d"
elif re.fullmatch(datetime_pattern, from_date) and re.fullmatch(
datetime_pattern, to_date
):
fmt = "%Y-%m-%dT%H:%M:%S"
else:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
start = _parse_boundary(from_date)
end = _parse_boundary(to_date)
try:
start = datetime.strptime(from_date, fmt)
end = datetime.strptime(to_date, fmt)
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
if start > end:
if start.date() > end.date():
raise ValueError("Date range start must be less than or equal to end")
if fmt == "%Y-%m-%d":
return start.date().isoformat(), end.date().isoformat()
return start.isoformat(), end.isoformat()
return start.date().isoformat(), end.date().isoformat()
def _run_init_config(config_path: str, force: bool) -> int:
@@ -99,10 +108,7 @@ def _run_init_config(config_path: str, force: bool) -> int:
"verify_ssl": True,
},
"period": {
"precision": "date",
"default_from": os.getenv("DEFAULT_FROM_DATE", "").strip(),
"default_to": os.getenv("DEFAULT_TO_DATE", "").strip(),
"dynamic": False,
"from": os.getenv("FROM_DATE", "").strip(),
},
"output": {
"dir": "",
@@ -155,29 +161,6 @@ def _run_init_config(config_path: str, force: bool) -> int:
return 0
def _compute_dedup_cutoff() -> Optional[datetime]:
"""Compute deduplication cutoff from config.
If period.precision == 'datetime' and period.last_used.to is set,
return the datetime to filter out entries already accounted for.
Otherwise return None.
"""
if Config.get_period_precision() != "datetime":
return None
last_to = Config.get_last_used_to()
if not last_to:
return None
try:
dt = datetime.fromisoformat(last_to.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except (ValueError, TypeError):
return None
def _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool:
"""Возвращает финальное значение no_time.
@@ -201,10 +184,11 @@ def _save_and_maybe_send(
to_date: str,
no_time: bool,
do_send: bool,
) -> int:
) -> tuple[int, bool]:
"""Сохраняет отчёт в файл и опционально отправляет по email.
Returns 0 on success, 1 on error.
Returns (exit_code, sent): sent отражает факт реальной отправки письма,
чтобы main мог отличить отказ от подтверждения от успешной отправки.
"""
output_ext = os.path.splitext(output_arg)[1].lower()
@@ -213,7 +197,7 @@ def _save_and_maybe_send(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr,
)
return 1
return 1, False
formatter = get_formatter_by_extension(
output_ext,
@@ -230,21 +214,20 @@ def _save_and_maybe_send(
file=sys.stderr,
)
else:
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
print(
f"❌ Неизвестный формат файла: {output_ext!r}. "
f"Поддерживаются: {known_exts}",
"Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx",
file=sys.stderr,
)
return 1
return 1, False
try:
formatter.save(rows, 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()
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
return 1
return 1, False
if do_send:
email_config = Config.get_email_config()
@@ -254,7 +237,20 @@ def _save_and_maybe_send(
"(~/.config/redmine-reporter/config.yml).",
file=sys.stderr,
)
return 1
return 1, False
print("\n📧 Готово к отправке:")
print(f" From: {email_config.from_}")
print(f" To: {', '.join(email_config.to)}")
if email_config.cc:
print(f" Cc: {', '.join(email_config.cc)}")
if email_config.bcc:
print(f" Bcc: {', '.join(email_config.bcc)}")
print(f" File: {output_arg}")
response = input("Отправить? [y/N]: ").strip().lower()
if response not in ("y", "yes"):
print("⏭️ Отправка отменена.")
return 0, False
try:
send_report(
@@ -267,12 +263,57 @@ def _save_and_maybe_send(
print(f"📧 Report sent to {', '.join(email_config.to)}")
except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
return 1
return 1, False
return 0, True
return 0, 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
def main(argv: Optional[List[str]] = None) -> int:
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="redmine-reporter",
description="Generate Redmine issue report based on your time entries.",
@@ -350,12 +391,17 @@ def main(argv: Optional[List[str]] = None) -> int:
parser.add_argument(
"--commit",
action="store_true",
help="Save used period as last_used in YAML config and auto-commit to file",
help="Save the report and advance period start to its end date in YAML config",
)
parser.add_argument(
"--send",
action="store_true",
help="Send generated report via email after saving (requires email section in config)",
help="Send generated report via email after saving (requires email section in config); on success advance period start to its end date",
)
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)
@@ -382,6 +428,25 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1
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]
if sum(bool(a) for a in user_args) > 1:
@@ -415,6 +480,10 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"❌ Configuration error: {e}", file=sys.stderr)
return 1
# Ветка --my-tasks: даты отчётного окна режиму не нужны.
if args.my_tasks:
return _run_my_tasks(args)
# Если --date не указан, используем дефолтный диапазон
date_arg = args.date if args.date is not None else Config.get_default_date_range()
try:
@@ -429,17 +498,18 @@ def main(argv: Optional[List[str]] = None) -> int:
to_date,
user_id=args.user_id or args.user_login or args.user_name,
by_activity=args.by_activity,
dedup_before=_compute_dedup_cutoff(),
)
except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
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
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")
logging.exception("Unexpected error") # noqa: LOG015
return 1
if not issue_hours:
@@ -472,6 +542,9 @@ def main(argv: Optional[List[str]] = None) -> int:
activity = key.split(":", 1)[1]
print(f" [{activity}]: {value}h", file=sys.stderr)
save_ok = False
report_sent = False
if args.output or args.commit:
if args.output:
output_arg = resolve_output_path(
@@ -502,7 +575,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
return 1
ret = _save_and_maybe_send(
ret, report_sent = _save_and_maybe_send(
rows,
output_arg,
author=Config.get_author(args.author),
@@ -513,6 +586,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
if ret != 0:
return ret
save_ok = True
elif args.send:
# --send без --output и --commit: сохраняем по шаблону и отправляем
@@ -533,7 +607,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
return 1
ret = _save_and_maybe_send(
ret, report_sent = _save_and_maybe_send(
rows,
output_arg,
author=Config.get_author(args.author),
@@ -544,6 +618,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
if ret != 0:
return ret
save_ok = True
else:
if args.compact:
@@ -558,36 +633,29 @@ def main(argv: Optional[List[str]] = None) -> int:
try:
output = formatter.format(rows)
print(output)
except Exception as e:
except Exception as e: # noqa: BLE001 -- консольный форматтер не должен ронять CLI: печатаем ошибку и возвращаем код 1
print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1
if args.commit:
precision = Config.get_period_precision()
dynamic = Config._app.period_dynamic if Config._app else False
if precision == "datetime":
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
# метки aware cutoff для дедупликации.
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
from_str = now
to_str = now
else:
from_str = from_date
to_str = to_date
# Окно двигается ровно один раз за запуск: либо по явному --commit
# (операция завершилась кодом 0), либо по фактически отправленному письму --send.
should_advance = (args.commit and save_ok) or (args.send and report_sent)
if should_advance:
# Начало следующего окна совпадает с концом текущего: граничный день
# перечитывается. Дубли записей дня коммита осознанны, зато поздние
# записи, добавленные в тот же день после отправки, не теряются.
next_from = to_date
try:
save_period_to_config(
args.config_path, from_str, to_str, precision, dynamic
)
except Exception as e:
save_period_to_config(args.config_path, from_str=next_from)
except Exception as e: # noqa: BLE001 -- сбой записи конфига не должен ронять CLI трейсбеком: сообщаем и возвращаем код 1
print(
f"❌ Не удалось сохранить период в конфиг: {e}",
file=sys.stderr,
)
return 1
print(
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
f"📌 Period committed [{next_from}] → {args.config_path}",
file=sys.stderr,
)

View File

@@ -1,8 +1,9 @@
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union
from datetime import date
from typing import Any
import requests
import truststore
from redminelib import Redmine
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
from redminelib.resources import Issue
@@ -20,13 +21,13 @@ ISSUE_ID_CHUNK_SIZE = 100
class RedmineAPIError(Exception):
"""Пользовательское исключение с понятным сообщением об ошибке Redmine API."""
def __init__(self, message: str, original: Optional[Exception] = None):
def __init__(self, message: str, original: Exception | None = None):
super().__init__(message)
self.message = message
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."""
api_key = Config.get_redmine_api_key()
if api_key:
@@ -49,12 +50,21 @@ def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
def _create_redmine() -> Redmine:
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24)."""
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24).
При verify=True подключает системное хранилище CA ОС через
truststore.inject_into_ssl() (#62).
"""
verify = Config.get_redmine_verify()
if verify is True:
# verify_ssl: true — проверка по системному хранилищу CA ОС (truststore),
# а не по certifi: корпоративные CA из ОС продолжают работать (#62).
truststore.inject_into_ssl()
redmine = Redmine(
Config.get_redmine_url(),
**_get_redmine_auth_kwargs(),
requests={
"verify": Config.get_redmine_verify(),
"verify": verify,
"timeout": REQUEST_TIMEOUT,
},
)
@@ -113,7 +123,7 @@ def _format_redmine_error(exc: Exception) -> str:
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.
Возвращает словарь id -> name. Если справочник недоступен,
@@ -122,7 +132,7 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
try:
activities = redmine.enumeration.filter(resource="time_entry_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: не глотаем сбой молча — предупреждаем, что разбивка по
# активностям будет построена по сырым данным из самих entries.
print(
@@ -133,7 +143,7 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
return {}
def _get_activity_name(entry, activities: Dict[int, str]) -> str:
def _get_activity_name(entry, activities: dict[int, str]) -> str:
"""Определяет название активности для time entry."""
activity = getattr(entry, "activity", None)
if activity is None:
@@ -153,36 +163,9 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str:
return str(activity)
def _parse_datetime(value: Any) -> Optional[datetime]:
"""Parse a datetime from Redmine API response.
Accepts datetime objects, ISO strings (with or without timezone),
or None. Returns a timezone-aware datetime or None.
Naive datetimes are treated as UTC (#58).
"""
if value is None:
return None
if isinstance(value, datetime):
return _ensure_aware_utc(value)
if isinstance(value, str):
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return _ensure_aware_utc(dt)
except (ValueError, TypeError):
return None
return None
def _ensure_aware_utc(dt: datetime) -> datetime:
"""Возвращает aware datetime; naive трактуется как UTC (#58)."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
def _fetch_issues_chunked(redmine: Redmine, issue_ids: list[int]) -> list[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = []
all_issues: list[Issue] = []
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
issue_list_str = ",".join(str(x) for x in chunk)
@@ -193,7 +176,7 @@ def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]
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.
Если аргумент — число, возвращает его как есть.
@@ -266,20 +249,14 @@ def _get_current_user_id(redmine: Redmine) -> int:
def fetch_issues_with_spent_time(
from_date: str,
to_date: str,
user_id: Optional[Union[int, str]] = None,
user_id: int | str | None = None,
by_activity: bool = False,
dedup_before: Optional[datetime] = None,
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]:
) -> list[tuple[Issue, float, dict[str, float] | None]] | None:
"""
Fetch unique issues linked to time entries of the given user in date range,
along with total spent hours per issue.
If user_id is None, uses current user.
If by_activity is True, returns per-activity breakdown as third tuple element.
If dedup_before is set, entries with both fields set are kept only when
created_on AND updated_on are both >= dedup_before; an entry is excluded
if at least one of the fields is before the cutoff (dedup_before).
Entries with both fields missing (None) are kept;
if only one field is set, that field alone decides (>= cutoff keeps the entry).
Returns list of (issue, total_hours, activities) tuples.
Raises RedmineAPIError on API/auth/network failures.
"""
@@ -294,7 +271,9 @@ def fetch_issues_with_spent_time(
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
time_entries = list(
redmine.time_entry.filter(
user_id=target_user_id, from_date=from_date, to_date=to_date
user_id=target_user_id,
from_date=from_date,
to_date=to_date,
)
)
except RedmineAPIError:
@@ -302,41 +281,9 @@ def fetch_issues_with_spent_time(
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте.
# Если оба поля заданы, запись сохраняется, только если created_on И updated_on
# оба >= dedup_before; если хотя бы одно из полей < dedup_before,
# запись исключается.
# Если оба поля None — запись сохраняется; если задано только одно поле,
# решает оно (>= dedup_before → запись сохраняется).
if dedup_before is not None:
# Нормализуем cutoff к aware UTC (#58): naive cutoff трактуем как UTC,
# чтобы сравнение с нормализованными created_on/updated_on было корректным.
dedup_before = _ensure_aware_utc(dedup_before)
filtered: list = []
try:
for entry in time_entries:
created = _parse_datetime(getattr(entry, "created_on", None))
updated = _parse_datetime(getattr(entry, "updated_on", None))
if created is None and updated is None:
filtered.append(entry)
elif created is not None and updated is not None:
if created >= dedup_before and updated >= dedup_before:
filtered.append(entry)
elif created is not None and created >= dedup_before:
filtered.append(entry)
elif updated is not None and updated >= dedup_before:
filtered.append(entry)
except TypeError as exc:
raise RedmineAPIError(
f"Failed to compare time entry dates with deduplication cutoff: {exc}",
original=exc,
) from exc
time_entries = filtered
# Агрегируем часы по issue.id (и активности, если требуется)
spent_time: Dict[int, float] = {}
spent_by_activity: Dict[int, Dict[str, float]] = {}
spent_time: dict[int, float] = {}
spent_by_activity: dict[int, dict[str, float]] = {}
issue_ids = set()
for entry in time_entries:
if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
@@ -387,3 +334,66 @@ def fetch_issues_with_spent_time(
result.append((issue, total_hours, activity_breakdown))
return result
def fetch_open_issues(
user_id: int | str | None = None,
) -> list[tuple[Issue, date | None]]:
"""
Fetch open issues assigned to the given user across all projects,
paired with the due date of each issue's fixed version.
If user_id is None, uses current user.
Returns list of (issue, due_date) tuples in API order; sorting is done
in the builder layer. Raises RedmineAPIError on API/auth/network failures.
"""
try:
redmine = _create_redmine()
target_user_id = (
_resolve_user_id(redmine, user_id)
if user_id is not None
else _get_current_user_id(redmine)
)
# Без limit: python-redmine автопагинацией вычитывает все страницы,
# как в time_entry.filter выше.
issues = list(
redmine.issue.filter(assigned_to_id=target_user_id, status_id="open")
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# fixed_version внутри задачи содержит только id/name, поэтому due_date
# приходится добирать отдельным запросом на каждую уникальную версию (#71).
# Отказ загрузки одной версии не валидует выгрузку: её задачи получат None.
due_by_version: dict[int, date | None] = {}
for issue in issues:
version_ref = getattr(issue, "fixed_version", None)
if version_ref is None:
continue
version_id = getattr(version_ref, "id", None)
if version_id is None or version_id in due_by_version:
continue
try:
version = redmine.version.get(version_id)
except Exception as exc: # noqa: BLE001 -- отказ загрузки одной версии не валидует выгрузку, деградируем к due_date=None
print(
f"⚠️ Could not load version {version_id}: {exc}. "
"Due date will be empty for its issues.",
file=sys.stderr,
)
due_by_version[version_id] = None
else:
due_by_version[version_id] = getattr(version, "due_date", None)
# Порядок задач сохраняем как в ответе API.
result: list[tuple[Issue, date | None]] = []
for issue in issues:
version_ref = getattr(issue, "fixed_version", None)
version_id = (
getattr(version_ref, "id", None) if version_ref is not None else None
)
due = due_by_version.get(version_id) if version_id is not None else None
result.append((issue, due))
return result

View File

@@ -2,9 +2,8 @@ import logging
import os
import sys
from dataclasses import dataclass, field
from datetime import date, timedelta
from datetime import date
from pathlib import Path
from typing import Dict, Union
import yaml
from dotenv import load_dotenv
@@ -13,7 +12,7 @@ from .yaml_config import check_file_permissions, resolve_env_vars
logger = logging.getLogger(__name__)
DEFAULT_REDMINE_VERIFY: Union[bool, str] = True
DEFAULT_REDMINE_VERIFY: bool | str = True
FALSE_VALUES = {"0", "false", "no", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"}
@@ -40,33 +39,22 @@ class EmailConfig:
html: bool = False
@dataclass
class PeriodLastUsed:
from_: str = ""
to: str = ""
@dataclass
class AppConfig:
redmine_url: str = ""
redmine_api_key: str = ""
redmine_author: str = ""
redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY
period_precision: str = "date"
period_default_from: str = ""
period_default_to: str = ""
period_dynamic: bool = False
period_last_used_from: str = ""
period_last_used_to: str = ""
redmine_verify: bool | str = DEFAULT_REDMINE_VERIFY
period_from: str = ""
output_dir: str = ""
output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx"
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)
@classmethod
def from_yaml(cls, path: Union[str, Path]) -> "AppConfig":
def from_yaml(cls, path: str | Path) -> "AppConfig":
"""Загружает настройки из YAML-файла.
Если файл не существует — возвращает конфиг со значениями по умолчанию.
@@ -100,12 +88,7 @@ class AppConfig:
redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"),
redmine_author=cls._resolve_str(raw, "redmine", "author"),
redmine_verify=cls._resolve_verify(raw),
period_precision=cls._resolve_str(raw, "period", "precision") or "date",
period_default_from=cls._resolve_str(raw, "period", "default_from"),
period_default_to=cls._resolve_str(raw, "period", "default_to"),
period_dynamic=cls._resolve_bool(raw, "period", "dynamic"),
period_last_used_from=cls._resolve_str(raw, "period", "last_used", "from"),
period_last_used_to=cls._resolve_str(raw, "period", "last_used", "to"),
period_from=cls._resolve_str(raw, "period", "from"),
output_dir=cls._resolve_str(raw, "output", "dir"),
output_filename=cls._resolve_str(raw, "output", "filename")
or "{author}_{from}_{to}.{ext}",
@@ -118,17 +101,6 @@ class AppConfig:
email=cls._resolve_email(raw),
)
@classmethod
def from_env(cls) -> "AppConfig":
"""Загружает настройки из переменных окружения."""
return cls(
redmine_url=os.getenv("REDMINE_URL", "").strip().rstrip("/"),
redmine_api_key=os.getenv("REDMINE_API_KEY", "").strip(),
redmine_author=os.getenv("REDMINE_AUTHOR", "").strip(),
period_default_from=os.getenv("DEFAULT_FROM_DATE", "").strip(),
period_default_to=os.getenv("DEFAULT_TO_DATE", "").strip(),
)
@staticmethod
def _resolve_str(raw: dict, section: str, *keys: str) -> str:
value = raw.get(section)
@@ -145,7 +117,7 @@ class AppConfig:
return str(value)
@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 значение логируется с warning и игнорируется.
@@ -167,7 +139,7 @@ class AppConfig:
type(value).__name__,
)
return {}
result: Dict[str, str] = {}
result: dict[str, str] = {}
for k, v in value.items():
if v is None or isinstance(v, (dict, list)):
logger.warning(
@@ -195,7 +167,7 @@ class AppConfig:
return False
@classmethod
def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
def _resolve_verify(cls, raw: dict) -> bool | str:
"""Семантика verify_ssl, единая с REDMINE_VERIFY из env:
- true (bool или строка) → True — стандартная проверка TLS (requests);
@@ -341,7 +313,7 @@ class Config:
return os.getenv("REDMINE_PASSWORD", "").strip()
@classmethod
def get_redmine_verify(cls) -> Union[bool, str]:
def get_redmine_verify(cls) -> bool | str:
value = os.getenv("REDMINE_VERIFY", "").strip()
if value:
normalized = value.lower()
@@ -365,24 +337,6 @@ class Config:
return cls._app.redmine_author
return ""
@classmethod
def get_period_precision(cls) -> str:
if cls._app:
return cls._app.period_precision or "date"
return "date"
@classmethod
def get_last_used_from(cls) -> str:
if cls._app:
return cls._app.period_last_used_from
return ""
@classmethod
def get_last_used_to(cls) -> str:
if cls._app:
return cls._app.period_last_used_to
return ""
@classmethod
def get_output_dir(cls) -> str:
if cls._app:
@@ -409,7 +363,7 @@ class Config:
return False
@classmethod
def get_status_translation(cls) -> Dict[str, str]:
def get_status_translation(cls) -> dict[str, str]:
"""Полный словарь перевода статусов: встроенный + переопределения из YAML.
Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется.
@@ -432,33 +386,34 @@ class Config:
@classmethod
def get_default_date_range(cls) -> str:
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
"""Окно отчёта по умолчанию: from..сегодня.
Приоритет начала: env FROM_DATE -> YAML period.from -> 1-е число
текущего месяца. Некорректная дата не роняет CLI -- логируется
warning и берётся текущий месяц.
"""
today = date.today()
today_str = today.isoformat()
raw_from = os.getenv("FROM_DATE", "").strip()
if not raw_from and cls._app:
raw_from = cls._app.period_from
if from_env:
return f"{from_env}--{to_env or today_str}"
if raw_from:
try:
start = date.fromisoformat(raw_from)
except (ValueError, TypeError):
logger.warning(
"Invalid FROM_DATE/period.from %r -- expected ISO date "
"(YYYY-MM-DD); using current month",
raw_from,
)
start = today.replace(day=1)
else:
start = today.replace(day=1)
if (
cls._app
and cls._app.period_dynamic
and cls._app.period_last_used_from
and cls._app.period_last_used_to
):
nf, nt = compute_next_period(
cls._app.period_last_used_from,
cls._app.period_last_used_to,
cls._app.period_precision or "date",
)
return f"{nf}--{nt}"
if cls._app and cls._app.period_default_from:
default_to = cls._app.period_default_to or today_str
return f"{cls._app.period_default_from}--{default_to}"
start = today.replace(day=1)
return f"{start.isoformat()}--{today_str}"
# Будущая start инвертирует диапазон и ломает фильтр Redmine,
# поэтому сжимаем окно до сегодняшнего дня.
start = min(start, today)
return f"{start.isoformat()}--{today.isoformat()}"
@classmethod
def validate(cls) -> None:
@@ -483,52 +438,3 @@ class Config:
raise ValueError(
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
)
def compute_next_period(
last_from: str, last_to: str, precision: str
) -> tuple[str, str]:
"""Compute the next report period based on the last committed period.
- For a full calendar month → next full calendar month.
- For an arbitrary range → same duration, starting the day after last_to.
- For datetime precision → same duration, starting 1 second after last_to.
"""
from datetime import datetime as dt_mod
if precision == "datetime":
fmt = "%Y-%m-%dT%H:%M:%S"
from_dt = dt_mod.fromisoformat(last_from.replace("Z", "+00:00"))
to_dt = dt_mod.fromisoformat(last_to.replace("Z", "+00:00"))
duration = to_dt - from_dt
next_from_dt = to_dt + timedelta(seconds=1)
next_to_dt = next_from_dt + duration
return next_from_dt.strftime(fmt), next_to_dt.strftime(fmt)
from_parts = last_from.split("-")
to_parts = last_to.split("-")
from_d = date(int(from_parts[0]), int(from_parts[1]), int(from_parts[2]))
to_d = date(int(to_parts[0]), int(to_parts[1]), int(to_parts[2]))
first_of_month = from_d.replace(day=1)
if from_d == first_of_month:
if to_d.month == 12:
last_of_month = date(to_d.year, 12, 31)
else:
next_first = date(to_d.year, to_d.month + 1, 1)
last_of_month = next_first - timedelta(days=1)
if to_d == last_of_month:
if to_d.month == 12:
nstart = date(to_d.year + 1, 1, 1)
else:
nstart = date(to_d.year, to_d.month + 1, 1)
if nstart.month == 12:
nend = date(nstart.year, 12, 31)
else:
nend = date(nstart.year, nstart.month + 1, 1) - timedelta(days=1)
return nstart.isoformat(), nend.isoformat()
duration = to_d - from_d
next_from = to_d + timedelta(days=1)
next_to = next_from + duration
return next_from.isoformat(), next_to.isoformat()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
import os
from datetime import date
from unittest import mock
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
PASSWORD_ENV = {
@@ -19,6 +20,18 @@ def _configure_current_user(mock_redmine, user_id=1):
mock_redmine.user.get.return_value = mock_user
@pytest.fixture(autouse=True)
def mock_truststore():
"""Мок truststore для всех тестов клиента.
При verify=True _create_redmine() вызывает truststore.inject_into_ssl(),
который глобально подменяет ssl.SSLContext без восстановления (#62).
Не даём реальной инъекции выполниться в pytest-процессе.
"""
with mock.patch("redmine_reporter.client.truststore") as m:
yield m
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
@@ -45,7 +58,7 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
assert result is not None
assert len(result) == 1
issue, total_hours, _activities = result[0]
_issue, total_hours, _activities = result[0]
assert total_hours == 3.5
@@ -172,6 +185,70 @@ def test_fetch_uses_custom_verify_path(mock_redmine_class):
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "true"}, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_true_injects_system_trust_store(mock_redmine_class, mock_truststore):
"""verify=True → truststore.inject_into_ssl() вызван, verify=True в Redmine (#62)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_called_once_with()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is True
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "false"}, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_false_skips_truststore_injection(mock_redmine_class, mock_truststore):
"""verify=False → truststore.inject_into_ssl() не вызывается."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_not_called()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is False
@mock.patch.dict(
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_custom_path_skips_truststore_injection(
mock_redmine_class, mock_truststore
):
"""verify=<путь к CA-bundle> → truststore.inject_into_ssl() не вызывается."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_not_called()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_default_injects_system_trust_store(mock_redmine_class, mock_truststore):
"""Без REDMINE_VERIFY verify по умолчанию True → inject вызван (#62)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_called_once_with()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is True
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
@@ -529,256 +606,6 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
assert len(result) == 250
# -- #47: Дедупликация time entries по datetime --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
"""Записи с created_on/updated_on раньше dedup_before исключаются."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
e1.updated_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
e2 = mock.MagicMock()
e2.issue.id = 2
e2.hours = 1.0
e2.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e2.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
mock_redmine.time_entry.filter.return_value = [e1, e2]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_issue2.project = "P"
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None
assert len(result) == 1
assert result[0][0].id == 2
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_filters_entries_with_old_created_even_if_updated_recently(
mock_redmine_class,
):
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
e1.updated_on = datetime(2026, 7, 1, 15, 0, 0, tzinfo=timezone.utc)
mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
"""Записи с created_on/updated_on >= dedup_before сохраняются."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1.updated_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None
assert len(result) == 1
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_entries_without_created_on_are_kept(mock_redmine_class):
"""Записи без created_on/updated_on не фильтруются (сохраняются)."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = None
e1.updated_on = None
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None
assert len(result) == 1
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_handles_string_created_on(mock_redmine_class):
"""created_on может быть строкой ISO — парсим корректно."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = "2026-07-01T10:00:00Z"
e1.updated_on = "2026-07-01T10:00:00Z"
mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_no_dedup_when_cutoff_is_none(mock_redmine_class):
"""Без dedup_before фильтрация не применяется."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = None
e1.updated_on = None
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=None)
assert result is not None
assert len(result) == 1
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
"""Смешанные записи: старые исключаются, новые сохраняются, аки идут."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e_old = mock.MagicMock()
e_old.issue.id = 10
e_old.hours = 1.0
e_old.created_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
e_old.updated_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
e_new = mock.MagicMock()
e_new.issue.id = 20
e_new.hours = 3.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e_akiy = mock.MagicMock()
e_akiy.issue.id = 30
e_akiy.hours = 5.0
e_akiy.created_on = None
e_akiy.updated_on = None
mock_redmine.time_entry.filter.return_value = [e_old, e_new, e_akiy]
mock_issue_new = mock.MagicMock()
mock_issue_new.id = 20
mock_issue_new.project = "P"
mock_issue_new.subject = "T"
mock_issue_new.status = "New"
mock_issue_akiy = mock.MagicMock()
mock_issue_akiy.id = 30
mock_issue_akiy.project = "P"
mock_issue_akiy.subject = "T2"
mock_issue_akiy.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None
assert len(result) == 2
ids = {r[0].id for r in result}
assert ids == {20, 30}
# -- Пагинация time entries (>100 записей) --
@@ -828,149 +655,6 @@ def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
assert hours_by_id[2] == 100.0
# -- #58: дедупликация при precision=datetime с naive created_on --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class):
"""python-redmine отдаёт naive created_on; cutoff aware (UTC) — фильтрация не падает.
Ожидаемое поведение после фикса #58: naive datetime трактуется как UTC,
старые записи отсекаются, новые сохраняются.
"""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e_old = mock.MagicMock()
e_old.issue.id = 1
e_old.hours = 2.0
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0) # naive, как из python-redmine
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
e_new = mock.MagicMock()
e_new.issue.id = 2
e_new.hours = 1.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0) # naive
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_issue2.project = "P"
mock_issue2.subject = "T"
mock_issue2.status = "New"
# API возвращает обе задачи: issue 1 попадёт в результат, только если
# её time entry не отсечён дедупликацией. Корректный фикс #58 отсекает
# e_old (created_on/updated_on < cutoff) -> [2]; неправильный фикс
# "погасить TypeError и оставить всё" даст [1, 2] и тест упадёт.
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None
assert [r[0].id for r in result] == [2]
# -- #58: нормализация datetime к aware UTC --
class TestParseDatetimeNormalization:
"""_parse_datetime всегда возвращает aware datetime; naive трактуется как UTC."""
def test_naive_datetime_treated_as_utc(self):
from datetime import datetime, timezone
from redmine_reporter.client import _parse_datetime
result = _parse_datetime(datetime(2026, 7, 1, 10, 0, 0))
assert result == datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
def test_aware_datetime_kept(self):
from datetime import datetime, timedelta, timezone
from redmine_reporter.client import _parse_datetime
aware = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone(timedelta(hours=3)))
assert _parse_datetime(aware) is aware
def test_naive_iso_string_treated_as_utc(self):
from datetime import timezone
from redmine_reporter.client import _parse_datetime
result = _parse_datetime("2026-07-01T10:00:00")
assert result is not None
assert result.tzinfo == timezone.utc
def test_aware_iso_string_keeps_offset(self):
from datetime import timedelta
from redmine_reporter.client import _parse_datetime
result = _parse_datetime("2026-07-01T10:00:00+03:00")
assert result is not None
assert result.utcoffset() == timedelta(hours=3)
def test_none_and_garbage_return_none(self):
from redmine_reporter.client import _parse_datetime
assert _parse_datetime(None) is None
assert _parse_datetime("not a date") is None
assert _parse_datetime(123) is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_accepts_naive_cutoff(mock_redmine_class):
"""Naive dedup_before трактуется как UTC — сравнение не падает (#58)."""
from datetime import datetime
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
naive_cutoff = datetime(2026, 7, 1, 12, 0, 0) # naive == UTC
e_old = mock.MagicMock()
e_old.issue.id = 1
e_old.hours = 2.0
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0)
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
e_new = mock.MagicMock()
e_new.issue.id = 2
e_new.hours = 1.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0)
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=naive_cutoff
)
assert result is not None
assert [r[0].id for r in result] == [2]
# -- #61: предупреждения о молчаливой потере данных --
@@ -1077,3 +761,245 @@ def test_load_time_entry_activities_returns_empty_on_failure(capsys):
captured = capsys.readouterr()
assert "activit" in captured.err.lower()
# -- Границы периода передаются в API как есть --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_passes_date_boundaries_to_api_unchanged(mock_redmine_class):
"""Флоринг границ выполняет cli-парсер, клиент отдаёт даты в filter как есть."""
mock_redmine = mock_redmine_class.return_value
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-09-30", "2026-10-31", user_id=458)
mock_redmine.time_entry.filter.assert_called_once_with(
user_id=458, from_date="2026-09-30", to_date="2026-10-31"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_works_without_dedup_argument(mock_redmine_class):
"""dedup-слой удалён: без dedup-аргумента все записи периода агрегируются."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
entry = mock.MagicMock()
entry.issue.id = 1
entry.hours = 2.0
entry.created_on = None
entry.updated_on = None
mock_redmine.time_entry.filter.return_value = [entry]
mock_issue = mock.MagicMock()
mock_issue.id = 1
mock_issue.project = "P"
mock_issue.subject = "T"
mock_issue.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue]
result = fetch_issues_with_spent_time("2026-09-30", "2026-09-30", user_id=123)
assert result is not None
assert [r[0].id for r in result] == [1]
assert result[0][1] == 2.0
# -- #71: fetch_open_issues --
def _make_open_issue(issue_id, version_id=None):
"""Создаёт mock открытой задачи; без version_id эмулирует отсутствие fixed_version.
Спека без fixed_version нужна, чтобы getattr(issue, "fixed_version", None)
вернул None, а не auto-атрибут MagicMock.
"""
if version_id is None:
issue = mock.MagicMock(spec=["id", "subject", "project"])
issue.id = issue_id
return issue
issue = mock.MagicMock()
issue.id = issue_id
issue.fixed_version.id = version_id
return issue
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_filters_by_user_and_open_status(mock_redmine_class):
"""issue.filter вызван ровно с assigned_to_id и status_id="open" (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=123, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_default_user_is_current(mock_redmine_class):
"""Без user_id резолвится текущий пользователь через user.get("current") (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=77)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.user.get.assert_called_once_with("current")
_, kwargs = mock_redmine.issue.filter.call_args
assert kwargs["assigned_to_id"] == 77
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_numeric_user_id_skips_user_lookup(mock_redmine_class):
"""Числовой user_id: user.get не вызывается, фильтр по переданному ID (#71)."""
mock_redmine = mock_redmine_class.return_value
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id=42)
mock_redmine.user.get.assert_not_called()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=42, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_resolves_login_to_id(mock_redmine_class):
"""Строковый логин разрешается в числовой ID как в fetch_issues_with_spent_time (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_user = mock.MagicMock()
mock_user.id = 7
mock_user.login = "ivanov"
mock_redmine.user.filter.return_value = [mock_user]
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id="ivanov")
login_filter_call = mock_redmine.user.filter.call_args_list[0]
assert login_filter_call.kwargs == {"login": "ivanov"}
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=7, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_fetches_each_unique_version_once(mock_redmine_class):
"""Две задачи с одной версией -> один version.get на версию (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2, version_id=5),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert mock_redmine.version.get.call_count == 2
version_ids = [call.args[0] for call in mock_redmine.version.get.call_args_list]
assert version_ids == [5, 6]
assert len(result) == 3
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_attaches_version_due_date(mock_redmine_class):
"""due_date из version.get попадает в кортеж задачи (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
assert result is not None
issue, due = result[0]
assert issue.id == 1
assert due == date(2026, 11, 1)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_issue_without_version_has_empty_due(mock_redmine_class):
"""Задача без fixed_version: version.get для неё не вызывается, due None (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
mock_redmine.version.get.assert_called_once_with(5)
assert result is not None
assert result[1][0].id == 2
assert result[1][1] is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_version_failure_warns_and_continues(mock_redmine_class, capsys):
"""Сбой version.get: warning в stderr, due None, общий результат не валится (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.side_effect = Exception("version boom")
result = fetch_open_issues()
assert result is not None
assert result[0][1] is None
captured = capsys.readouterr()
assert "Could not load version 5" in captured.err
assert "version boom" in captured.err
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_result_length_matches_issues(mock_redmine_class):
"""Результат по длине и порядку соответствует списку задач из API (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert result is not None
assert len(result) == 3
assert [issue.id for issue, _ in result] == [1, 2, 3]
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_wraps_api_errors(mock_redmine_class):
"""Сбой issue.filter оборачивается в RedmineAPIError с понятным сообщением (#71)."""
from redminelib.exceptions import AuthError
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.side_effect = AuthError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Authentication failed"):
fetch_open_issues()

View File

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

View File

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

View File

@@ -65,7 +65,7 @@ class TestBuildMessage:
data = buf.getvalue()
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):
"""Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть."""
@@ -74,7 +74,7 @@ class TestBuildMessage:
)
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.get_payload(decode=True).decode("utf-8")
@@ -205,7 +205,7 @@ class TestBuildMessage:
]
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")
assert "Project X" in payload
assert "Important task" in payload
@@ -228,9 +228,9 @@ class TestBuildMessage:
]
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"
][0]
)
payloads = alternative.get_payload()
assert payloads[0].get_content_type() == "text/plain"
assert payloads[1].get_content_type() == "text/html"
@@ -273,9 +273,11 @@ class TestSendReport:
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")):
with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"):
send_report(cfg, str(report), "A", "P", [])
with (
mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")),
pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"),
):
send_report(cfg, str(report), "A", "P", [])
def test_send_auth_error(self, tmp_path):
report = tmp_path / "report.xlsx"
@@ -297,9 +299,11 @@ class TestSendReport:
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=TimeoutError()):
with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"):
send_report(cfg, str(report), "A", "P", [])
with (
mock.patch("smtplib.SMTP", side_effect=TimeoutError()),
pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"),
):
send_report(cfg, str(report), "A", "P", [])
def test_send_generic_smtp_error(self, tmp_path):
report = tmp_path / "report.xlsx"

350
tests/test_task_list.py Normal file
View File

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

161
tests/test_task_table.py Normal file
View File

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

View File

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

View File

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