Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b926dd0d49 | ||
|
|
80faccb1f9 | ||
|
|
9b78d66769 | ||
|
|
485be063d2 | ||
|
|
47152f8f04 | ||
|
|
67350bfcd6 | ||
|
|
0fa4e271a7 | ||
|
|
b1a565bc9e | ||
|
|
c962a93f30 | ||
|
|
676f7ede30 | ||
|
|
5dd234e7b3 | ||
|
|
59af7ce464 | ||
|
|
f6861382e6 | ||
|
|
67b5d093d9 | ||
|
|
f80f3a8b52 | ||
|
|
222d31730e | ||
|
|
86f083aa79 | ||
|
|
a82be05b83 | ||
|
|
ca89832d74 | ||
|
|
f6afc4096d | ||
|
|
738d9d543e | ||
|
|
58fa5a7ab4 | ||
|
|
14219564dd | ||
|
|
da069993b9 | ||
|
|
dbc4cf960a | ||
|
|
3a6d1b7ba7 | ||
|
|
3956decd4e |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -88,7 +88,7 @@ secrets.json
|
|||||||
|
|
||||||
# Just in case
|
# Just in case
|
||||||
.~*
|
.~*
|
||||||
report.odt
|
# Local report outputs
|
||||||
report.csv
|
report.*
|
||||||
report.md
|
rep.*
|
||||||
*.html
|
*.html
|
||||||
|
|||||||
214
README.md
214
README.md
@@ -8,15 +8,20 @@ CLI-инструмент для генерации отчётов по зада
|
|||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Получение time entries текущего пользователя из Redmine.
|
- Получение time entries **текущего** или **указанного** пользователя из Redmine.
|
||||||
- Авторизация через Redmine API token.
|
- Авторизация через Redmine API token или логин/пароль.
|
||||||
- Резервная авторизация через логин и пароль для обратной совместимости.
|
|
||||||
- Группировка задач по проекту и версии.
|
- Группировка задач по проекту и версии.
|
||||||
- Перевод статусов задач на русский язык.
|
- Перевод статусов задач на русский язык.
|
||||||
- Вывод в консоль в табличном или компактном виде.
|
- Разбивка по типам активности (`--by-activity`).
|
||||||
- Экспорт в ODT, CSV, Markdown и HTML.
|
- Вывод в консоль (таблица / компактный вид).
|
||||||
- Автоматическое определение месяца ODT-отчёта по конечной дате периода.
|
- Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
|
||||||
- Настройка периода отчёта по умолчанию через `.env`.
|
- Excel-отчёт с merge-ячейками по проекту/версии, итогами, автошириной, автофильтром и закреплённой шапкой.
|
||||||
|
- Сводка по времени (`--summary`).
|
||||||
|
- YAML-конфиг (`~/.config/redmine-reporter/config.yml`): шаблон имени файла, путь по умолчанию, период, SMTP.
|
||||||
|
- Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание.
|
||||||
|
- `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска.
|
||||||
|
- Понятные сообщения об ошибках Redmine API (401/403/5xx, таймаут, сеть).
|
||||||
|
- Загрузка альтернативного `.env` через `--config`.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -29,31 +34,74 @@ pip install --upgrade pip
|
|||||||
pip install .
|
pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Для разработки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
## Настройка
|
## Настройка
|
||||||
|
|
||||||
Создайте файл `.env` в корне проекта. Файл не должен попадать в git.
|
Источники конфигурации (от высшего приоритета к низшему):
|
||||||
|
|
||||||
Рекомендуемый вариант авторизации:
|
```
|
||||||
|
CLI-флаги > переменные окружения > .env > YAML-конфиг > кодовые дефолты
|
||||||
|
```
|
||||||
|
|
||||||
|
### YAML-конфиг (основной способ)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сгенерировать YAML из текущего .env
|
||||||
|
redmine-reporter --init-config
|
||||||
|
|
||||||
|
# Редактировать под себя
|
||||||
|
vim ~/.config/redmine-reporter/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
redmine:
|
||||||
|
url: https://red.eltex.loc
|
||||||
|
api_key: ${REDMINE_API_KEY}
|
||||||
|
author: "Кокос А.А."
|
||||||
|
verify_ssl: true
|
||||||
|
|
||||||
|
period:
|
||||||
|
precision: date # date | datetime
|
||||||
|
default_from: "2026-06-01"
|
||||||
|
default_to: "2026-06-30"
|
||||||
|
dynamic: false
|
||||||
|
# last_used заполняется --commit (см. docs/CONFIG.md)
|
||||||
|
|
||||||
|
output:
|
||||||
|
dir: ~/reports
|
||||||
|
filename: "{author}_{from}_{to}.{ext}"
|
||||||
|
default_format: xlsx
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp:
|
||||||
|
host: smtp.example.com
|
||||||
|
port: 587
|
||||||
|
user: bot@example.com
|
||||||
|
password: ${SMTP_PASSWORD}
|
||||||
|
tls: true
|
||||||
|
from: bot@example.com
|
||||||
|
to:
|
||||||
|
- boss@example.com
|
||||||
|
subject: "Отчёт {author} за {period}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Шаблон `output.filename` поддерживает `{author}`, `{from}`, `{to}`, `{date}` (DD_MM_YYYY), `{ext}`.
|
||||||
|
|
||||||
|
Подробнее: [docs/CONFIG.md](docs/CONFIG.md).
|
||||||
|
|
||||||
|
### `.env` (legacy)
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
REDMINE_URL=https://red.eltex.loc/
|
REDMINE_URL=https://red.eltex.loc/
|
||||||
REDMINE_API_KEY=ваш_api_token
|
REDMINE_API_KEY=ваш_api_token
|
||||||
REDMINE_AUTHOR=Иванов Иван Иванович
|
REDMINE_AUTHOR=Иванов Иван Иванович
|
||||||
|
|
||||||
DEFAULT_FROM_DATE=2026-01-01
|
|
||||||
DEFAULT_TO_DATE=2026-01-31
|
|
||||||
```
|
|
||||||
|
|
||||||
Если задан `REDMINE_API_KEY`, он используется в первую очередь.
|
|
||||||
|
|
||||||
Резервный вариант авторизации:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
REDMINE_URL=https://red.eltex.loc/
|
|
||||||
REDMINE_USER=ваш.логин
|
|
||||||
REDMINE_PASSWORD=ваш_пароль
|
|
||||||
REDMINE_AUTHOR=Иванов Иван Иванович
|
|
||||||
|
|
||||||
DEFAULT_FROM_DATE=2026-01-01
|
DEFAULT_FROM_DATE=2026-01-01
|
||||||
DEFAULT_TO_DATE=2026-01-31
|
DEFAULT_TO_DATE=2026-01-31
|
||||||
```
|
```
|
||||||
@@ -66,19 +114,10 @@ DEFAULT_TO_DATE=2026-01-31
|
|||||||
| `REDMINE_API_KEY` | Да, если нет логина и пароля | Redmine API token. |
|
| `REDMINE_API_KEY` | Да, если нет логина и пароля | Redmine API token. |
|
||||||
| `REDMINE_USER` | Да, если нет токена | Логин Redmine. |
|
| `REDMINE_USER` | Да, если нет токена | Логин Redmine. |
|
||||||
| `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. |
|
| `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. |
|
||||||
| `REDMINE_AUTHOR` | Нет | Имя автора для ODT-отчёта. |
|
| `REDMINE_AUTHOR` | Нет | Имя автора для отчёта. |
|
||||||
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию в формате `YYYY-MM-DD`. |
|
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). |
|
||||||
| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию в формате `YYYY-MM-DD`. |
|
| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию (`YYYY-MM-DD`). |
|
||||||
| `REDMINE_VERIFY` | Нет | Настройка TLS-проверки для Redmine API. |
|
| `REDMINE_VERIFY` | Нет | TLS-проверка: `true` / `false` / путь к CA bundle. |
|
||||||
|
|
||||||
`REDMINE_VERIFY` поддерживает значения:
|
|
||||||
|
|
||||||
- пустое значение или отсутствие переменной: `/etc/ssl/certs/ca-certificates.crt`;
|
|
||||||
- `true`, `1`, `yes`, `on`: стандартная проверка сертификатов `requests`;
|
|
||||||
- `false`, `0`, `no`, `off`: отключить проверку сертификатов;
|
|
||||||
- любой другой текст: путь к CA bundle.
|
|
||||||
|
|
||||||
Отключать проверку сертификатов не рекомендуется.
|
|
||||||
|
|
||||||
## Использование
|
## Использование
|
||||||
|
|
||||||
@@ -92,60 +131,117 @@ source .venv/bin/activate
|
|||||||
redmine-reporter
|
redmine-reporter
|
||||||
```
|
```
|
||||||
|
|
||||||
Отчёт за произвольный период:
|
Произвольный период:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
redmine-reporter --date 2026-02-01--2026-02-28
|
redmine-reporter --date 2026-02-01--2026-02-28
|
||||||
```
|
```
|
||||||
|
|
||||||
Период должен быть задан в формате `YYYY-MM-DD--YYYY-MM-DD`. Начальная дата не может быть позже конечной.
|
Другой пользователь:
|
||||||
|
|
||||||
Компактный вывод:
|
```bash
|
||||||
|
redmine-reporter --user-id 42
|
||||||
|
redmine-reporter --user-login ivanov
|
||||||
|
redmine-reporter --user-name "Иванов И.И."
|
||||||
|
```
|
||||||
|
|
||||||
|
Переопределить URL / API-ключ:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --url https://red.example.com --api-key ваш_токен
|
||||||
|
```
|
||||||
|
|
||||||
|
Альтернативный `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --config /path/to/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Компактный / отладочный вывод:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
redmine-reporter --compact
|
redmine-reporter --compact
|
||||||
|
redmine-reporter --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
Экспорт:
|
Экспорт с явным путём:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
redmine-reporter --output report.odt
|
redmine-reporter --output report.xlsx
|
||||||
redmine-reporter --output report.csv
|
redmine-reporter --output /path/to/report.odt
|
||||||
redmine-reporter --output report.md
|
|
||||||
redmine-reporter --output report.html
|
|
||||||
```
|
```
|
||||||
|
|
||||||
ODT-отчёт:
|
Экспорт — только формат (путь и имя берутся из YAML-шаблона):
|
||||||
|
|
||||||
- месяц в заголовке определяется по `to_date`;
|
```bash
|
||||||
- имя автора берётся из `--author`, затем из `REDMINE_AUTHOR`;
|
redmine-reporter --output xlsx # → output.dir/отчёт_01_07_2026.xlsx
|
||||||
- если автор не задан, поле автора остаётся пустым.
|
redmine-reporter --output odt # → output.dir/отчёт_01_07_2026.odt
|
||||||
|
```
|
||||||
|
|
||||||
Вывод без затраченного времени:
|
Экспорт — путь без расширения (дописывается `default_format` из конфига):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --output /tmp/report # → /tmp/report.xlsx (если default_format: xlsx)
|
||||||
|
```
|
||||||
|
|
||||||
|
Без времени / с разбивкой по активностям:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
redmine-reporter --no-time
|
redmine-reporter --no-time
|
||||||
|
redmine-reporter --by-activity
|
||||||
|
redmine-reporter --by-activity --summary
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Сводка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --summary
|
||||||
|
```
|
||||||
|
|
||||||
|
Фиксация периода (`--commit`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сгенерировать, сохранить в файл по шаблону, запомнить период
|
||||||
|
redmine-reporter --commit
|
||||||
|
|
||||||
|
# С явным путём
|
||||||
|
redmine-reporter --commit --output report.xlsx
|
||||||
|
|
||||||
|
# Следующий запуск (без --date) возьмёт следующий период автоматически
|
||||||
|
redmine-reporter
|
||||||
|
|
||||||
|
# При precision=datetime запоминает момент времени
|
||||||
|
# (предотвращает дублирование записей внутри дня)
|
||||||
|
redmine-reporter --commit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Форматы вывода
|
||||||
|
|
||||||
|
| Формат | Особенности |
|
||||||
|
| --- | --- |
|
||||||
|
| **ODT** | Заголовок с автором и месяцем, группировка по проекту/версии. |
|
||||||
|
| **CSV** | UTF-8 с BOM, полные значения `project`/`version` в каждой строке. |
|
||||||
|
| **Markdown** | Компактная таблица, повторяющиеся группы скрыты. |
|
||||||
|
| **HTML** | Полноценный HTML-документ с `meta charset="utf-8"`. |
|
||||||
|
| **JSON** | Массив объектов: `project`, `version`, `issue_id`, `subject`, `status`, `time`. |
|
||||||
|
| **Excel (.xlsx)** | Merge cells, колонки `Hours`/`Spent Time`, итоги, автоширина, автофильтр, freeze panes. |
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
Установка зависимостей для разработки:
|
Проверки перед коммитом:
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -e ".[dev]"
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверки:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest
|
pytest
|
||||||
ruff check redmine_reporter tests
|
ruff check redmine_reporter tests
|
||||||
black redmine_reporter tests
|
black --check redmine_reporter tests
|
||||||
isort redmine_reporter tests
|
isort --check-only redmine_reporter tests
|
||||||
|
mypy redmine_reporter
|
||||||
```
|
```
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- Не коммитьте `.env`, API token, пароль или логин.
|
- Не коммитьте `.env`, API token, пароль или логин.
|
||||||
|
- YAML-конфиг имеет права `0600`, директория — `0700`.
|
||||||
|
- Рекомендуется хранить секреты через `${VAR}`, а не plaintext.
|
||||||
- Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач.
|
- Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач.
|
||||||
- Инструмент работает только в режиме чтения и не изменяет данные в Redmine.
|
- Инструмент работает только в режиме чтения и не изменяет данные в Redmine.
|
||||||
|
|||||||
259
docs/CONFIG.md
Normal file
259
docs/CONFIG.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# Настройка redmine-reporter
|
||||||
|
|
||||||
|
## Источники конфигурации
|
||||||
|
|
||||||
|
Приоритет, от высшего к низшему:
|
||||||
|
|
||||||
|
```
|
||||||
|
CLI-флаги > переменные окружения > .env > YAML > кодовые дефолты
|
||||||
|
```
|
||||||
|
|
||||||
|
Если значение не задано на верхнем уровне, берётся уровень ниже. `.env` и YAML
|
||||||
|
работают одновременно — можно оставить оба, можно удалить `.env` после миграции.
|
||||||
|
|
||||||
|
## YAML-конфиг
|
||||||
|
|
||||||
|
Основной файл: `~/.config/redmine-reporter/config.yml`.
|
||||||
|
|
||||||
|
Создаётся с правами `0600` (владелец: чтение/запись, остальные: доступ запрещён).
|
||||||
|
Директория `~/.config/redmine-reporter/` — с правами `0700`.
|
||||||
|
|
||||||
|
Если права файла шире `0600`, при запуске выводится предупреждение.
|
||||||
|
|
||||||
|
### Структура
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
redmine:
|
||||||
|
url: https://red.eltex.loc
|
||||||
|
api_key: ${REDMINE_API_KEY}
|
||||||
|
author: "Кокос А.А."
|
||||||
|
verify_ssl: true
|
||||||
|
|
||||||
|
period:
|
||||||
|
precision: date
|
||||||
|
default_from: "2026-06-01"
|
||||||
|
default_to: "2026-06-30"
|
||||||
|
dynamic: false
|
||||||
|
last_used:
|
||||||
|
from: "2026-06-30T09:00:00"
|
||||||
|
to: "2026-06-30T12:00:00"
|
||||||
|
|
||||||
|
output:
|
||||||
|
dir: ~/reports
|
||||||
|
filename: "{author}_{from}_{to}.{ext}"
|
||||||
|
default_format: xlsx
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp:
|
||||||
|
host: smtp.example.com
|
||||||
|
port: 587
|
||||||
|
user: bot@example.com
|
||||||
|
password: ${SMTP_PASSWORD}
|
||||||
|
tls: true
|
||||||
|
from: bot@example.com
|
||||||
|
to:
|
||||||
|
- boss@example.com
|
||||||
|
cc: []
|
||||||
|
bcc: []
|
||||||
|
subject: "Отчёт {author} за {period}"
|
||||||
|
body_text: "Во вложении отчёт."
|
||||||
|
attach: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### `period.precision` — точность периода
|
||||||
|
|
||||||
|
Определяет, как вычисляется следующий период после фиксации:
|
||||||
|
|
||||||
|
- `date` (по умолчанию) — период с точностью до дня. Следующий запуск (после `--commit`) начинается со следующего дня.
|
||||||
|
- `datetime` — период с точностью до секунды. При повторном запуске time entries с `created_on` и `updated_on` ранее `last_used.to` исключаются (AND-логика: запись исключается только если **оба** поля раньше cutoff). Это предотвращает дублирование при отправке отчёта внутри рабочего дня.
|
||||||
|
|
||||||
|
`last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется.
|
||||||
|
|
||||||
|
### `--commit` — автофиксация периода
|
||||||
|
|
||||||
|
Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода.
|
||||||
|
|
||||||
|
**Что делает:**
|
||||||
|
|
||||||
|
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`:**
|
||||||
|
|
||||||
|
- `dynamic: true` — следующий запуск (без `--date`) вычисляет период от `last_used`:
|
||||||
|
- Полный календарный месяц → следующий полный месяц.
|
||||||
|
- Произвольный диапазон → та же длительность, начиная со дня после `last_used.to`.
|
||||||
|
- `dynamic: false` — `--commit` перезаписывает `default_from`/`default_to` на использованный период.
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Июнь 2026 → следующий запуск (без --date) → июль 2026
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30 --commit
|
||||||
|
|
||||||
|
# Произвольный диапазон: 15-20 июня → следующий запуск → 21-26 июня
|
||||||
|
redmine-reporter --date 2026-06-15--2026-06-20 --commit
|
||||||
|
|
||||||
|
# С datetime-точностью: повторный запуск не дублирует записи
|
||||||
|
redmine-reporter --commit
|
||||||
|
```
|
||||||
|
|
||||||
|
### `output` — путь и имя файла по умолчанию
|
||||||
|
|
||||||
|
Секция управляет тем, куда и с каким именем сохраняется отчёт, когда `--output` не содержит полного пути.
|
||||||
|
|
||||||
|
**Правила разрешения `--output`:**
|
||||||
|
|
||||||
|
| Аргумент `--output` | Поведение |
|
||||||
|
|---|---|
|
||||||
|
| `/полный/путь/report.xlsx` | Используется как есть, конфиг игнорируется |
|
||||||
|
| `xlsx` (bare format: `xlsx`, `odt`, `csv`, `md`, `html`, `json`) | Путь = `output.dir` + `output.filename`, расширение = bare format |
|
||||||
|
| `/tmp/report` (путь без расширения) | Дописывается `.default_format` → `/tmp/report.xlsx` |
|
||||||
|
|
||||||
|
**Шаблон имени файла:**
|
||||||
|
|
||||||
|
`output.filename` поддерживает подстановки:
|
||||||
|
|
||||||
|
| Плейсхолдер | Описание | Пример |
|
||||||
|
|---|---|---|
|
||||||
|
| `{author}` | Имя автора (пробелы → `_`) | `Кокос_А.А.` |
|
||||||
|
| `{from}` | Начало периода, `YYYY-MM-DD` | `2026-06-01` |
|
||||||
|
| `{to}` | Конец периода, `YYYY-MM-DD` | `2026-06-30` |
|
||||||
|
| `{date}` | Конец периода, `DD_MM_YYYY` | `30_06_2026` |
|
||||||
|
| `{ext}` | Расширение файла без точки | `xlsx` |
|
||||||
|
|
||||||
|
Неизвестные плейсхолдеры остаются в имени как есть.
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# По умолчанию
|
||||||
|
filename: "{author}_{from}_{to}.{ext}" # → Кокос_А.А._2026-06-01_2026-06-30.xlsx
|
||||||
|
|
||||||
|
# Русский формат даты
|
||||||
|
filename: "отчёт_{date}.{ext}" # → отчёт_30_06_2026.xlsx
|
||||||
|
|
||||||
|
# Без автора, только диапазон
|
||||||
|
filename: "report_{from}--{to}.{ext}" # → report_2026-06-01--2026-06-30.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Подстановка переменных окружения
|
||||||
|
|
||||||
|
В любом строковом значении YAML можно использовать `${VAR}` — при загрузке
|
||||||
|
оно заменяется на значение переменной окружения:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
redmine:
|
||||||
|
api_key: ${REDMINE_API_KEY}
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp:
|
||||||
|
password: ${SMTP_PASSWORD}
|
||||||
|
```
|
||||||
|
|
||||||
|
Это безопаснее, чем хранить секреты plaintext в YAML. Однако plaintext-секреты
|
||||||
|
**не запрещены** — если вписать `api_key: "abc123"` напрямую, система примет.
|
||||||
|
Права `0600` — основная защита.
|
||||||
|
|
||||||
|
## Разрешение выходного пути
|
||||||
|
|
||||||
|
Функция `resolve_output_path()` определяет итоговый путь к файлу:
|
||||||
|
|
||||||
|
1. `--output` не указан → консольный вывод.
|
||||||
|
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` (неизвестное расширение) → используется как есть, форматтер выбирается по расширению.
|
||||||
|
|
||||||
|
## Миграция с `.env` на YAML
|
||||||
|
|
||||||
|
### Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Генерируем YAML из текущего .env
|
||||||
|
redmine-reporter --init-config
|
||||||
|
|
||||||
|
# 2. Проверяем, что создалось
|
||||||
|
cat ~/.config/redmine-reporter/config.yml
|
||||||
|
|
||||||
|
# 3. Редактируем под себя (шаблон имени, период, etc.)
|
||||||
|
vim ~/.config/redmine-reporter/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что делает `--init-config`
|
||||||
|
|
||||||
|
- Читает текущие значения из `.env` и переменных окружения.
|
||||||
|
- Формирует YAML со всеми секциями (`redmine`, `period`, `output`, `email`).
|
||||||
|
- Секреты (`REDMINE_API_KEY`, `SMTP_PASSWORD`) записывает как `${VAR}`, если
|
||||||
|
переменная существует, иначе — пустая строка.
|
||||||
|
- Создаёт файл с правами `0600`, директорию — с `0700`.
|
||||||
|
|
||||||
|
### Флаги миграции
|
||||||
|
|
||||||
|
| Флаг | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `--init-config` | Создать YAML и выйти |
|
||||||
|
| `--init-config --force` | Перезаписать существующий YAML |
|
||||||
|
| `--config-path PATH` | Сохранить YAML по указанному пути (по умолчанию `~/.config/redmine-reporter/config.yml`) |
|
||||||
|
|
||||||
|
### Проверка после миграции
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запустить без .env в текущей директории
|
||||||
|
cd /tmp
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30
|
||||||
|
```
|
||||||
|
|
||||||
|
Если отработал — YAML-конфиг читается корректно. Если `REDMINE_URL is required` —
|
||||||
|
проверь права:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la ~/.config/redmine-reporter/
|
||||||
|
# config.yml должно быть -rw------- (600)
|
||||||
|
# директория должна быть drwx------ (700)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сосуществование `.env` и YAML
|
||||||
|
|
||||||
|
Можно оставить оба источника. `.env` имеет **более высокий приоритет**, чем YAML:
|
||||||
|
|
||||||
|
```
|
||||||
|
.env значения переопределяют YAML, если заданы
|
||||||
|
YAML работает как базовый слой для всего, что не в .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Это safe — если с YAML что-то пойдёт не так, просто положи `.env` обратно.
|
||||||
|
|
||||||
|
### Откат
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm ~/.config/redmine-reporter/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.env` (legacy)
|
||||||
|
|
||||||
|
Для обратной совместимости `.env` продолжает работать без изменений.
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц
|
||||||
|
как период, стандартный путь сертификатов, пустой автор).
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- YAML-конфиг: права `0600`, директория `0700`.
|
||||||
|
- Права шире `0600` → warning в stderr при каждом запуске.
|
||||||
|
- Секреты рекомендуется хранить через `${VAR}`, а не plaintext.
|
||||||
|
- `.env` **не рекомендуется** для постоянных настроек — оставьте его только
|
||||||
|
для CI/CD или временных переопределений.
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "redmine-reporter"
|
name = "redmine-reporter"
|
||||||
version = "1.5.0"
|
version = "1.9.0"
|
||||||
description = "Redmine time-entry based issue reporter for internal use"
|
description = "Redmine time-entry based issue reporter for internal use"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
||||||
@@ -23,6 +23,8 @@ dependencies = [
|
|||||||
"tabulate>=0.9.0",
|
"tabulate>=0.9.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"odfpy>=1.4.0",
|
"odfpy>=1.4.0",
|
||||||
|
"openpyxl>=3.1.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -56,5 +58,5 @@ multi_line_output = 3
|
|||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = ["odf.*", "redminelib.*", "tabulate"]
|
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.5.0"
|
__version__ = "1.9.0"
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from .client import fetch_issues_with_spent_time
|
import yaml
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .client import RedmineAPIError, fetch_issues_with_spent_time
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .formatters.factory import get_console_formatter, get_formatter_by_extension
|
from .formatters.factory import get_console_formatter, get_formatter_by_extension
|
||||||
from .report_builder import build_grouped_report
|
from .report_builder import build_grouped_report, calculate_summary
|
||||||
|
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
|
||||||
|
|
||||||
|
|
||||||
def parse_date_range(date_arg: str) -> tuple[str, str]:
|
def parse_date_range(date_arg: str) -> tuple[str, str]:
|
||||||
if "--" not in date_arg:
|
if "--" not in date_arg:
|
||||||
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
||||||
parts = date_arg.split("--", 1)
|
parts = date_arg.split("--", 1)
|
||||||
if len(parts) != 2:
|
|
||||||
raise ValueError("Invalid date range format")
|
|
||||||
|
|
||||||
from_date, to_date = parts[0].strip(), parts[1].strip()
|
from_date, to_date = parts[0].strip(), parts[1].strip()
|
||||||
date_pattern = r"\d{4}-\d{2}-\d{2}"
|
date_pattern = r"\d{4}-\d{2}-\d{2}"
|
||||||
@@ -35,6 +39,89 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
|
|||||||
return start.isoformat(), end.isoformat()
|
return start.isoformat(), end.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_init_config(config_path: str, force: bool) -> int:
|
||||||
|
"""Создаёт YAML-конфиг из текущих настроек окружения."""
|
||||||
|
path = Path(config_path)
|
||||||
|
|
||||||
|
if path.exists() and not force:
|
||||||
|
print(
|
||||||
|
f"⚠️ {path} already exists.\n" f" Use --init-config --force to overwrite.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Собираем значения из окружения
|
||||||
|
data = {
|
||||||
|
"redmine": {
|
||||||
|
"url": os.getenv("REDMINE_URL", "").strip().rstrip("/"),
|
||||||
|
"api_key": ("${REDMINE_API_KEY}" if os.getenv("REDMINE_API_KEY") else ""),
|
||||||
|
"author": os.getenv("REDMINE_AUTHOR", "").strip(),
|
||||||
|
"verify_ssl": True,
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"precision": "date",
|
||||||
|
"default_from": os.getenv("DEFAULT_FROM_DATE", "").strip(),
|
||||||
|
"default_to": os.getenv("DEFAULT_TO_DATE", "").strip(),
|
||||||
|
"dynamic": False,
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"dir": "",
|
||||||
|
"filename": "{author}_{from}_{to}.{ext}",
|
||||||
|
"default_format": "xlsx",
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"smtp": {
|
||||||
|
"host": "",
|
||||||
|
"port": 587,
|
||||||
|
"user": "",
|
||||||
|
"password": ("${SMTP_PASSWORD}" if os.getenv("SMTP_PASSWORD") else ""),
|
||||||
|
"tls": True,
|
||||||
|
},
|
||||||
|
"from": "",
|
||||||
|
"to": [],
|
||||||
|
"cc": [],
|
||||||
|
"bcc": [],
|
||||||
|
"subject": "Отчёт {author} за {period}",
|
||||||
|
"body_text": "Во вложении отчёт.",
|
||||||
|
"attach": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_config_dir(path.parent)
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
yaml.dump(data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
sections_found = [s for s in data if data[s]]
|
||||||
|
print(f"✅ Config written to {path}")
|
||||||
|
print(f" Sections: {', '.join(sections_found)}")
|
||||||
|
print(" Secrets stored as ${VAR} references where detected.")
|
||||||
|
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 main(argv: Optional[List[str]] = None) -> int:
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="redmine-reporter",
|
prog="redmine-reporter",
|
||||||
@@ -42,9 +129,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--date",
|
"--date",
|
||||||
default=Config.get_default_date_range(),
|
default=None,
|
||||||
# help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: %(default)s)"
|
help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: current month or from .env)",
|
||||||
help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default from .env or %(default)s)",
|
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--compact",
|
"--compact",
|
||||||
@@ -53,7 +139,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output",
|
"--output",
|
||||||
help="Path to output file (.odt, .csv, .md, .html). If omitted, prints to stdout.",
|
help="Path to output file (.odt, .csv, .md, .html, .json, .xlsx). If omitted, prints to stdout.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"
|
"--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"
|
||||||
@@ -61,40 +147,194 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-time", action="store_true", help="Do not include spent time into table"
|
"--no-time", action="store_true", help="Do not include spent time into table"
|
||||||
)
|
)
|
||||||
|
parser.add_argument("--url", help="Override Redmine URL from .env (REDMINE_URL)")
|
||||||
|
parser.add_argument("--api-key", help="Override Redmine API key from .env (REDMINE_API_KEY)")
|
||||||
|
parser.add_argument("--config", help="Path to .env config file")
|
||||||
|
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Enable debug output")
|
||||||
|
parser.add_argument(
|
||||||
|
"--version",
|
||||||
|
action="version",
|
||||||
|
version=f"%(prog)s {__version__}",
|
||||||
|
help="Show version and exit",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--summary",
|
||||||
|
action="store_true",
|
||||||
|
help="Print summary (total hours by project/version) to stderr",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--user-id",
|
||||||
|
help="Redmine user ID for the report (default: current user)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--user-login",
|
||||||
|
help="Redmine user login for the report (alternative to --user-id)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--user-name",
|
||||||
|
help="Redmine user full name for the report (alternative to --user-id; ambiguous names are rejected)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--by-activity",
|
||||||
|
action="store_true",
|
||||||
|
help="Break down spent time by activity type",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--init-config",
|
||||||
|
action="store_true",
|
||||||
|
help="Generate YAML config from current environment and exit",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Allow overwriting existing config with --init-config",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config-path",
|
||||||
|
default=str(Path.home() / ".config" / "redmine-reporter" / "config.yml"),
|
||||||
|
help="Path for --init-config output (default: ~/.config/redmine-reporter/config.yml)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--commit",
|
||||||
|
action="store_true",
|
||||||
|
help="Save used period as last_used in YAML config and auto-commit to file",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
# --init-config: обработка до всего остального
|
||||||
|
if args.init_config:
|
||||||
|
# Проверка на взаимоисключающие флаги
|
||||||
|
report_flags = [
|
||||||
|
args.date is not None,
|
||||||
|
args.output,
|
||||||
|
args.compact,
|
||||||
|
args.summary,
|
||||||
|
args.user_id,
|
||||||
|
args.user_login,
|
||||||
|
args.user_name,
|
||||||
|
args.no_time,
|
||||||
|
args.by_activity,
|
||||||
|
]
|
||||||
|
if any(report_flags):
|
||||||
|
print(
|
||||||
|
"❌ --init-config cannot be used with report-generating flags.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
return _run_init_config(args.config_path, args.force)
|
||||||
|
|
||||||
|
# Валидация взаимоисключающих флагов пользователя
|
||||||
|
user_args = [args.user_id, args.user_login, args.user_name]
|
||||||
|
if sum(bool(a) for a in user_args) > 1:
|
||||||
|
print(
|
||||||
|
"❌ Specify only one of --user-id, --user-login, or --user-name.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# CLI-переопределения имеют приоритет над .env/env.
|
||||||
|
if args.config:
|
||||||
|
Config.load_config(args.config)
|
||||||
|
Config.set_redmine_url(args.url)
|
||||||
|
Config.set_redmine_api_key(args.api_key)
|
||||||
|
|
||||||
|
# Автозагрузка YAML-конфига
|
||||||
|
yaml_path = args.config_path
|
||||||
|
Config.load_yaml(yaml_path)
|
||||||
|
|
||||||
|
# Настройка уровня логирования
|
||||||
|
if args.debug:
|
||||||
|
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
|
||||||
|
elif args.verbose:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||||
|
else:
|
||||||
|
logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Config.validate()
|
Config.validate()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
print(f"❌ Configuration error: {e}", file=sys.stderr)
|
print(f"❌ Configuration error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# Если --date не указан, используем дефолтный диапазон
|
||||||
|
date_arg = args.date if args.date is not None else Config.get_default_date_range()
|
||||||
try:
|
try:
|
||||||
from_date, to_date = parse_date_range(args.date)
|
from_date, to_date = parse_date_range(date_arg)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
print(f"❌ Date error: {e}", file=sys.stderr)
|
print(f"❌ Date error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
issue_hours = fetch_issues_with_spent_time(from_date, to_date)
|
issue_hours = fetch_issues_with_spent_time(
|
||||||
|
from_date,
|
||||||
|
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"❌ {e.message}", file=sys.stderr)
|
||||||
|
if args.debug and e.original is not None:
|
||||||
|
logging.exception("Original Redmine API error")
|
||||||
|
return 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Redmine API error: {e}", file=sys.stderr)
|
print(f"❌ Unexpected error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
if issue_hours is None:
|
if issue_hours is None:
|
||||||
print("ℹ️ No time entries found in the given period.", file=sys.stderr)
|
print("ℹ️ No time entries found in the given period.", file=sys.stderr)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]")
|
if not issue_hours:
|
||||||
|
print("ℹ️ No time entries found in the given period.", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
rows = build_grouped_report(issue_hours, fill_time=not args.no_time)
|
print(f"✅ Total issues: {len(issue_hours)} [{date_arg}]", file=sys.stderr)
|
||||||
|
|
||||||
|
rows = build_grouped_report(
|
||||||
|
issue_hours,
|
||||||
|
fill_time=not args.no_time,
|
||||||
|
by_activity=args.by_activity,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.summary:
|
||||||
|
summary = calculate_summary(rows, by_activity=args.by_activity)
|
||||||
|
print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr)
|
||||||
|
project_keys = [k for k in sorted(summary) if k.startswith("project:")]
|
||||||
|
activity_keys = [k for k in sorted(summary) if k.startswith("activity:")]
|
||||||
|
for key in project_keys + activity_keys:
|
||||||
|
value = summary[key]
|
||||||
|
if key.startswith("project:"):
|
||||||
|
project = key.split(":", 1)[1]
|
||||||
|
print(f" {project}: {value}h", file=sys.stderr)
|
||||||
|
elif key.startswith("activity:"):
|
||||||
|
activity = key.split(":", 1)[1]
|
||||||
|
print(f" [{activity}]: {value}h", file=sys.stderr)
|
||||||
|
|
||||||
if args.output:
|
if args.output:
|
||||||
output_ext = os.path.splitext(args.output)[1].lower()
|
output_arg = resolve_output_path(
|
||||||
|
args.output,
|
||||||
|
output_dir=Config.get_output_dir(),
|
||||||
|
filename_template=Config.get_output_filename(),
|
||||||
|
default_format=Config.get_output_default_format(),
|
||||||
|
author=Config.get_author(args.author),
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
if output_arg is None:
|
||||||
|
print(
|
||||||
|
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
output_ext = os.path.splitext(output_arg)[1].lower()
|
||||||
|
|
||||||
if not output_ext:
|
if not output_ext:
|
||||||
print(
|
print(
|
||||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md или .html",
|
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
@@ -104,33 +344,68 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
author=Config.get_author(args.author),
|
author=Config.get_author(args.author),
|
||||||
from_date=from_date,
|
from_date=from_date,
|
||||||
to_date=to_date,
|
to_date=to_date,
|
||||||
|
no_time=args.no_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not formatter:
|
if not formatter:
|
||||||
known_exts = ", ".join([".odt", ".csv", ".md", ".html"])
|
|
||||||
print(
|
|
||||||
f"❌ Неизвестный формат файла: {output_ext!r}. Поддерживаются: {known_exts}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
formatter.save(rows, args.output)
|
|
||||||
print(f"✅ Report saved to {args.output}")
|
|
||||||
except ImportError as e:
|
|
||||||
if output_ext == ".odt":
|
if output_ext == ".odt":
|
||||||
print(
|
print(
|
||||||
"❌ odfpy is not installed. Install with: pip install odfpy",
|
"❌ odfpy is not installed. Install with: pip install odfpy",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print(f"❌ Import error: {e}", file=sys.stderr)
|
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
|
||||||
|
print(
|
||||||
|
f"❌ Неизвестный формат файла: {output_ext!r}. "
|
||||||
|
f"Поддерживаются: {known_exts}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
formatter.save(rows, output_arg)
|
||||||
|
print(f"✅ Report saved to {output_arg}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
fmt = output_ext.lstrip(".").upper()
|
fmt = output_ext.lstrip(".").upper()
|
||||||
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
|
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
elif args.commit:
|
||||||
|
default_format = Config.get_output_default_format()
|
||||||
|
output_arg = resolve_output_path(
|
||||||
|
default_format,
|
||||||
|
output_dir=Config.get_output_dir(),
|
||||||
|
filename_template=Config.get_output_filename(),
|
||||||
|
default_format=default_format,
|
||||||
|
author=Config.get_author(args.author),
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
if output_arg is None:
|
||||||
|
print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
output_ext = os.path.splitext(output_arg)[1].lower()
|
||||||
|
formatter = get_formatter_by_extension(
|
||||||
|
output_ext,
|
||||||
|
author=Config.get_author(args.author),
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
no_time=args.no_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not formatter:
|
||||||
|
print(f"❌ Не удалось создать форматтер для {output_ext}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
formatter.save(rows, output_arg)
|
||||||
|
print(f"✅ Report saved to {output_arg}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Export error: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if args.compact:
|
if args.compact:
|
||||||
formatter = get_console_formatter("compact")
|
formatter = get_console_formatter("compact")
|
||||||
@@ -148,6 +423,26 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
print(f"❌ Formatting error: {e}", file=sys.stderr)
|
print(f"❌ Formatting error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
if args.commit:
|
||||||
|
precision = Config.get_period_precision()
|
||||||
|
dynamic = Config._app.period_dynamic if Config._app else False
|
||||||
|
|
||||||
|
if precision == "datetime":
|
||||||
|
from datetime import datetime as dt_mod
|
||||||
|
|
||||||
|
now = dt_mod.now().isoformat(timespec="seconds")
|
||||||
|
from_str = now
|
||||||
|
to_str = now
|
||||||
|
else:
|
||||||
|
from_str = from_date
|
||||||
|
to_str = to_date
|
||||||
|
|
||||||
|
save_period_to_config(args.config_path, from_str, to_str, precision, dynamic)
|
||||||
|
print(
|
||||||
|
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
from typing import Any, Dict, List, Optional, Tuple
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import requests
|
||||||
from redminelib import Redmine
|
from redminelib import Redmine
|
||||||
|
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
|
||||||
from redminelib.resources import Issue
|
from redminelib.resources import Issue
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .utils import get_version
|
|
||||||
|
# Таймаут на один HTTP-запрос к Redmine (секунды).
|
||||||
|
REQUEST_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Размер чанка для запроса задач по issue_id, чтобы не превышать лимит длины URL (#21).
|
||||||
|
ISSUE_ID_CHUNK_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineAPIError(Exception):
|
||||||
|
"""Пользовательское исключение с понятным сообщением об ошибке Redmine API."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, original: Optional[Exception] = 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]:
|
||||||
@@ -18,49 +36,295 @@ def _get_redmine_auth_kwargs() -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def fetch_issues_with_spent_time(
|
def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
|
||||||
from_date: str, to_date: str
|
"""Создаёт HTTPAdapter с retry для временных ошибок (#24)."""
|
||||||
) -> Optional[List[Tuple[Issue, float]]]:
|
retry = Retry(
|
||||||
"""
|
total=3,
|
||||||
Fetch unique issues linked to time entries of the current user in given date range,
|
backoff_factor=0.5,
|
||||||
along with total spent hours per issue.
|
status_forcelist=[429, 500, 502, 503, 504],
|
||||||
Returns list of (issue, total_hours) tuples.
|
allowed_methods=["GET", "HEAD", "OPTIONS"],
|
||||||
"""
|
)
|
||||||
|
return requests.adapters.HTTPAdapter(max_retries=retry)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_redmine() -> Redmine:
|
||||||
|
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24)."""
|
||||||
redmine = Redmine(
|
redmine = Redmine(
|
||||||
Config.get_redmine_url(),
|
Config.get_redmine_url(),
|
||||||
**_get_redmine_auth_kwargs(),
|
**_get_redmine_auth_kwargs(),
|
||||||
requests={"verify": Config.get_redmine_verify()},
|
requests={
|
||||||
|
"verify": Config.get_redmine_verify(),
|
||||||
|
"timeout": REQUEST_TIMEOUT,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
current_user = redmine.user.get("current")
|
# Монтируем retry-адаптер на сессию для автоматических повторов.
|
||||||
time_entries = redmine.time_entry.filter(
|
# В python-redmine сессия живёт в engine, а redmine.session — контекстный менеджер.
|
||||||
user_id=current_user.id, from_date=from_date, to_date=to_date
|
retry_adapter = _make_retry_adapter()
|
||||||
|
redmine.engine.session.mount("https://", retry_adapter)
|
||||||
|
redmine.engine.session.mount("http://", retry_adapter)
|
||||||
|
|
||||||
|
return redmine
|
||||||
|
|
||||||
|
|
||||||
|
def _format_redmine_error(exc: Exception) -> str:
|
||||||
|
"""Преобразует исключение Redmine/requests в понятное сообщение."""
|
||||||
|
if isinstance(exc, AuthError):
|
||||||
|
return (
|
||||||
|
"Authentication failed: invalid API key, login or password. "
|
||||||
|
"Check REDMINE_API_KEY / REDMINE_USER / REDMINE_PASSWORD."
|
||||||
|
)
|
||||||
|
if isinstance(exc, ForbiddenError):
|
||||||
|
return (
|
||||||
|
"Access denied: your Redmine account does not have permission "
|
||||||
|
"to read time entries or issues."
|
||||||
|
)
|
||||||
|
if isinstance(exc, ResourceNotFoundError):
|
||||||
|
return "Requested Redmine resource not found: check user/project identifiers."
|
||||||
|
|
||||||
|
# requests HTTPError может быть обёрнуто в python-redmine
|
||||||
|
original = getattr(exc, "response", None)
|
||||||
|
if original is None:
|
||||||
|
original = exc
|
||||||
|
|
||||||
|
response = getattr(original, "response", None)
|
||||||
|
if response is not None and hasattr(response, "status_code"):
|
||||||
|
status = response.status_code
|
||||||
|
if status == 401:
|
||||||
|
return "Authentication failed (HTTP 401): check your API key or login/password."
|
||||||
|
if status == 403:
|
||||||
|
return "Access denied (HTTP 403): insufficient Redmine permissions."
|
||||||
|
if status == 404:
|
||||||
|
return "Redmine endpoint not found (HTTP 404): check REDMINE_URL."
|
||||||
|
if status == 429:
|
||||||
|
return "Too many requests (HTTP 429): Redmine rate limit exceeded."
|
||||||
|
if 500 <= status < 600:
|
||||||
|
return f"Redmine server error (HTTP {status}): try again later."
|
||||||
|
return f"Redmine API returned HTTP {status}."
|
||||||
|
|
||||||
|
if isinstance(exc, requests.exceptions.Timeout):
|
||||||
|
return f"Redmine request timed out after {REQUEST_TIMEOUT} seconds."
|
||||||
|
if isinstance(exc, requests.exceptions.ConnectionError):
|
||||||
|
return "Cannot connect to Redmine: check the URL and network."
|
||||||
|
if isinstance(exc, requests.exceptions.RequestException):
|
||||||
|
return f"Network error while calling Redmine: {exc}"
|
||||||
|
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
|
||||||
|
"""Загружает справочник типов активности time entries.
|
||||||
|
|
||||||
|
Возвращает словарь id -> name. Если справочник недоступен,
|
||||||
|
возвращает пустой словарь — тогда будем использовать данные из самих entries.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
activities = redmine.enumeration.filter(resource="time_entry_activities")
|
||||||
|
return {int(a.id): str(a.name) for a in activities}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_activity_name(entry, activities: Dict[int, str]) -> str:
|
||||||
|
"""Определяет название активности для time entry."""
|
||||||
|
activity = getattr(entry, "activity", None)
|
||||||
|
if activity is None:
|
||||||
|
return "<N/A>"
|
||||||
|
|
||||||
|
# activity может быть объектом с id/name или просто значением
|
||||||
|
activity_id = getattr(activity, "id", None)
|
||||||
|
if activity_id is not None:
|
||||||
|
name = activities.get(int(activity_id))
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
activity_name = getattr(activity, "name", None)
|
||||||
|
if activity_name:
|
||||||
|
return str(activity_name)
|
||||||
|
return str(activity_id)
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
return dt
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
|
||||||
|
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
|
||||||
|
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)
|
||||||
|
issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc")
|
||||||
|
all_issues.extend(issues)
|
||||||
|
return all_issues
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int:
|
||||||
|
"""Преобразует строковый идентификатор пользователя в числовой ID.
|
||||||
|
|
||||||
|
Если аргумент — число, возвращает его как есть.
|
||||||
|
Если строка, пытается найти пользователя по логину или имени.
|
||||||
|
"""
|
||||||
|
if isinstance(user_arg, int):
|
||||||
|
return user_arg
|
||||||
|
|
||||||
|
text = str(user_arg).strip()
|
||||||
|
if not text:
|
||||||
|
raise RedmineAPIError("User identifier cannot be empty.")
|
||||||
|
|
||||||
|
# Сначала пробуем интерпретировать как числовой ID
|
||||||
|
if text.isdigit():
|
||||||
|
return int(text)
|
||||||
|
|
||||||
|
# Затем ищем по логину
|
||||||
|
try:
|
||||||
|
users = redmine.user.filter(login=text)
|
||||||
|
if users:
|
||||||
|
return int(users[0].id)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RedmineAPIError(
|
||||||
|
f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}",
|
||||||
|
original=exc,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# Потом по имени
|
||||||
|
try:
|
||||||
|
users = redmine.user.filter(name=text)
|
||||||
|
if len(users) == 1:
|
||||||
|
return int(users[0].id)
|
||||||
|
if len(users) > 1:
|
||||||
|
matches = ", ".join(str(getattr(u, "login", u.id)) for u in users[:5])
|
||||||
|
raise RedmineAPIError(
|
||||||
|
f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID."
|
||||||
|
)
|
||||||
|
except RedmineAPIError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise RedmineAPIError(
|
||||||
|
f"Cannot resolve user name '{text}': {_format_redmine_error(exc)}",
|
||||||
|
original=exc,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
raise RedmineAPIError(
|
||||||
|
f"User '{text}' not found. Check the login/name or use --user-id with numeric Redmine ID."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Агрегируем часы по issue.id
|
|
||||||
|
def _get_current_user_id(redmine: Redmine) -> int:
|
||||||
|
"""Возвращает ID текущего пользователя."""
|
||||||
|
try:
|
||||||
|
current_user = redmine.user.get("current")
|
||||||
|
return int(current_user.id)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_issues_with_spent_time(
|
||||||
|
from_date: str,
|
||||||
|
to_date: str,
|
||||||
|
user_id: Optional[Union[int, str]] = None,
|
||||||
|
by_activity: bool = False,
|
||||||
|
dedup_before: Optional[datetime] = None,
|
||||||
|
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]:
|
||||||
|
"""
|
||||||
|
Fetch unique issues linked to time entries of the given user in date range,
|
||||||
|
along with total spent hours per issue.
|
||||||
|
If user_id is None, uses current user.
|
||||||
|
If by_activity is True, returns per-activity breakdown as third tuple element.
|
||||||
|
If dedup_before is set, filters out time entries whose created_on AND updated_on
|
||||||
|
are both before dedup_before (AND logic: both must be < cutoff to exclude).
|
||||||
|
Returns list of (issue, total_hours, activities) tuples.
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
except RedmineAPIError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
||||||
|
|
||||||
|
# Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте.
|
||||||
|
# Запись исключается, если BOTH created_on AND updated_on < dedup_before.
|
||||||
|
# Записи без метаданных (created_on/updated_on == None) не фильтруются.
|
||||||
|
if dedup_before is not None:
|
||||||
|
filtered: list = []
|
||||||
|
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)
|
||||||
|
time_entries = filtered
|
||||||
|
|
||||||
|
# Агрегируем часы по issue.id (и активности, если требуется)
|
||||||
spent_time: Dict[int, float] = {}
|
spent_time: Dict[int, float] = {}
|
||||||
|
spent_by_activity: Dict[int, Dict[str, float]] = {}
|
||||||
issue_ids = set()
|
issue_ids = set()
|
||||||
for entry in time_entries:
|
for entry in time_entries:
|
||||||
if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
|
if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
|
||||||
iid = entry.issue.id
|
iid = entry.issue.id
|
||||||
|
hours = float(entry.hours)
|
||||||
issue_ids.add(iid)
|
issue_ids.add(iid)
|
||||||
spent_time[iid] = spent_time.get(iid, 0.0) + float(entry.hours)
|
spent_time[iid] = spent_time.get(iid, 0.0) + hours
|
||||||
|
|
||||||
|
if by_activity:
|
||||||
|
activity_name = _get_activity_name(entry, activities_lookup)
|
||||||
|
by_act = spent_by_activity.setdefault(iid, {})
|
||||||
|
by_act[activity_name] = by_act.get(activity_name, 0.0) + hours
|
||||||
|
|
||||||
if not issue_ids:
|
if not issue_ids:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Загружаем полные объекты задач
|
# Загружаем полные объекты задач чанками (#21)
|
||||||
issue_list_str = ",".join(str(i) for i in issue_ids)
|
try:
|
||||||
issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc")
|
sorted_ids = sorted(issue_ids)
|
||||||
|
issues = _fetch_issues_chunked(redmine, sorted_ids)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
||||||
|
|
||||||
# Сопоставляем задачи с суммарным временем
|
# Сопоставляем задачи с суммарным временем.
|
||||||
|
# Сортировка выполняется в report_builder.build_grouped_report,
|
||||||
|
# здесь оставляем порядок API как есть.
|
||||||
result = []
|
result = []
|
||||||
for issue in issues:
|
for issue in issues:
|
||||||
total_hours = spent_time.get(issue.id, 0.0)
|
iid = issue.id
|
||||||
result.append((issue, total_hours))
|
if iid not in spent_time:
|
||||||
|
continue
|
||||||
# Сортируем по (проект, версия)
|
total_hours = spent_time[iid]
|
||||||
result.sort(key=lambda x: (str(x[0].project), get_version(x[0])))
|
activity_breakdown = spent_by_activity.get(iid) if by_activity else None
|
||||||
|
result.append((issue, total_hours, activity_breakdown))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,23 +1,278 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
|
import yaml
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
from .yaml_config import check_file_permissions, resolve_env_vars
|
||||||
|
|
||||||
|
load_dotenv(override=False)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
|
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
|
||||||
FALSE_VALUES = {"0", "false", "no", "off"}
|
FALSE_VALUES = {"0", "false", "no", "off"}
|
||||||
TRUE_VALUES = {"1", "true", "yes", "on"}
|
TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SmtpConfig:
|
||||||
|
host: str = ""
|
||||||
|
port: int = 587
|
||||||
|
user: str = ""
|
||||||
|
password: str = ""
|
||||||
|
tls: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EmailConfig:
|
||||||
|
smtp: SmtpConfig = field(default_factory=SmtpConfig)
|
||||||
|
from_: str = ""
|
||||||
|
to: list = field(default_factory=list)
|
||||||
|
cc: list = field(default_factory=list)
|
||||||
|
bcc: list = field(default_factory=list)
|
||||||
|
subject: str = "Отчёт {author} за {period}"
|
||||||
|
body_text: str = "Во вложении отчёт."
|
||||||
|
attach: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@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 = ""
|
||||||
|
output_dir: str = ""
|
||||||
|
output_filename: str = "{author}_{from}_{to}.{ext}"
|
||||||
|
output_default_format: str = "xlsx"
|
||||||
|
email: EmailConfig = field(default_factory=EmailConfig)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_yaml(cls, path: Union[str, Path]) -> "AppConfig":
|
||||||
|
"""Загружает настройки из YAML-файла.
|
||||||
|
|
||||||
|
Если файл не существует — возвращает конфиг со значениями по умолчанию.
|
||||||
|
Неизвестные ключи верхнего уровня логируются с warning.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
if not path.exists():
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
# Проверяем права файла
|
||||||
|
for warning in check_file_permissions(path):
|
||||||
|
logger.warning(warning)
|
||||||
|
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
raw = yaml.safe_load(fh) or {}
|
||||||
|
|
||||||
|
# Предупреждаем о неизвестных ключах верхнего уровня
|
||||||
|
known_keys = {
|
||||||
|
"redmine",
|
||||||
|
"period",
|
||||||
|
"output",
|
||||||
|
"email",
|
||||||
|
}
|
||||||
|
for key in raw:
|
||||||
|
if key not in known_keys:
|
||||||
|
logger.warning("Unknown top-level key in config: %s", key)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
redmine_url=cls._resolve_str(raw, "redmine", "url"),
|
||||||
|
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"),
|
||||||
|
output_dir=cls._resolve_str(raw, "output", "dir"),
|
||||||
|
output_filename=cls._resolve_str(raw, "output", "filename")
|
||||||
|
or "{author}_{from}_{to}.{ext}",
|
||||||
|
output_default_format=cls._resolve_str(raw, "output", "default_format") or "xlsx",
|
||||||
|
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)
|
||||||
|
for key in keys:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = value.get(key)
|
||||||
|
else:
|
||||||
|
value = None
|
||||||
|
break
|
||||||
|
if isinstance(value, str):
|
||||||
|
return resolve_env_vars(value)
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
|
||||||
|
value = raw.get(section, {}).get(key)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.lower()
|
||||||
|
if normalized in TRUE_VALUES:
|
||||||
|
return True
|
||||||
|
if normalized in FALSE_VALUES:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
|
||||||
|
value = raw.get("redmine", {}).get("verify_ssl")
|
||||||
|
if value is None:
|
||||||
|
return DEFAULT_REDMINE_VERIFY
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return DEFAULT_REDMINE_VERIFY if value else False
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.lower()
|
||||||
|
if normalized in FALSE_VALUES:
|
||||||
|
return False
|
||||||
|
if normalized in TRUE_VALUES:
|
||||||
|
return DEFAULT_REDMINE_VERIFY
|
||||||
|
return resolve_env_vars(value)
|
||||||
|
return DEFAULT_REDMINE_VERIFY
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_email(cls, raw: dict) -> EmailConfig:
|
||||||
|
email_raw = raw.get("email", {}) or {}
|
||||||
|
smtp_raw = email_raw.get("smtp", {}) or {}
|
||||||
|
|
||||||
|
return EmailConfig(
|
||||||
|
smtp=SmtpConfig(
|
||||||
|
host=cls._safe_str(smtp_raw.get("host")),
|
||||||
|
port=cls._safe_int(smtp_raw.get("port"), 587),
|
||||||
|
user=cls._safe_str(smtp_raw.get("user")),
|
||||||
|
password=resolve_env_vars(cls._safe_str(smtp_raw.get("password"))),
|
||||||
|
tls=cls._safe_bool(smtp_raw.get("tls"), True),
|
||||||
|
),
|
||||||
|
from_=cls._safe_str(email_raw.get("from")),
|
||||||
|
to=cls._safe_list(email_raw.get("to")),
|
||||||
|
cc=cls._safe_list(email_raw.get("cc")),
|
||||||
|
bcc=cls._safe_list(email_raw.get("bcc")),
|
||||||
|
subject=resolve_env_vars(
|
||||||
|
cls._safe_str(email_raw.get("subject")) or "Отчёт {author} за {period}"
|
||||||
|
),
|
||||||
|
body_text=resolve_env_vars(
|
||||||
|
cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт."
|
||||||
|
),
|
||||||
|
attach=cls._safe_bool(email_raw.get("attach"), True),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_str(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_int(value, default: int = 0) -> int:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_bool(value, default: bool = False) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.lower()
|
||||||
|
if normalized in TRUE_VALUES:
|
||||||
|
return True
|
||||||
|
if normalized in FALSE_VALUES:
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_list(value) -> list:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(v) for v in value]
|
||||||
|
return [str(value)]
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
_cli_url: str | None = None
|
||||||
|
_cli_api_key: str | None = None
|
||||||
|
_app: AppConfig | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_yaml(cls, path: str) -> None:
|
||||||
|
"""Загружает YAML-конфиг. Не бросает исключений при отсутствии файла."""
|
||||||
|
cls._app = AppConfig.from_yaml(path)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_config(cls, path: str) -> None:
|
||||||
|
"""Загружает переменные из указанного .env-файла с override."""
|
||||||
|
load_dotenv(path, override=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_redmine_url(cls, url: str) -> None:
|
||||||
|
cls._cli_url = url.strip().rstrip("/") if url else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_redmine_api_key(cls, key: str) -> None:
|
||||||
|
cls._cli_api_key = key.strip() if key else None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_redmine_url(cls) -> str:
|
def get_redmine_url(cls) -> str:
|
||||||
return os.getenv("REDMINE_URL", "").strip().rstrip("/")
|
if cls._cli_url is not None:
|
||||||
|
return cls._cli_url
|
||||||
|
env = os.getenv("REDMINE_URL", "").strip().rstrip("/")
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
if cls._app:
|
||||||
|
return cls._app.redmine_url.strip().rstrip("/")
|
||||||
|
return ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_redmine_api_key(cls) -> str:
|
def get_redmine_api_key(cls) -> str:
|
||||||
return os.getenv("REDMINE_API_KEY", "").strip()
|
if cls._cli_api_key is not None:
|
||||||
|
return cls._cli_api_key
|
||||||
|
env = os.getenv("REDMINE_API_KEY", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
if cls._app:
|
||||||
|
return cls._app.redmine_api_key
|
||||||
|
return ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_redmine_user(cls) -> str:
|
def get_redmine_user(cls) -> str:
|
||||||
@@ -25,36 +280,100 @@ class Config:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_redmine_password(cls) -> str:
|
def get_redmine_password(cls) -> str:
|
||||||
return os.getenv("REDMINE_PASSWORD", "")
|
return os.getenv("REDMINE_PASSWORD", "").strip()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_redmine_verify(cls) -> Union[bool, str]:
|
def get_redmine_verify(cls) -> Union[bool, str]:
|
||||||
value = os.getenv("REDMINE_VERIFY", "").strip()
|
value = os.getenv("REDMINE_VERIFY", "").strip()
|
||||||
if not value:
|
if value:
|
||||||
return DEFAULT_REDMINE_VERIFY
|
normalized = value.lower()
|
||||||
|
if normalized in FALSE_VALUES:
|
||||||
normalized = value.lower()
|
return False
|
||||||
if normalized in FALSE_VALUES:
|
if normalized in TRUE_VALUES:
|
||||||
return False
|
return True
|
||||||
if normalized in TRUE_VALUES:
|
return value
|
||||||
return True
|
if cls._app:
|
||||||
return value
|
return cls._app.redmine_verify
|
||||||
|
return DEFAULT_REDMINE_VERIFY
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_author(cls, cli_author: str = "") -> str:
|
def get_author(cls, cli_author: str = "") -> str:
|
||||||
"""Возвращает автора: из CLI если задан, иначе из .env, иначе — заглушку."""
|
|
||||||
if cli_author:
|
if cli_author:
|
||||||
return cli_author
|
return cli_author
|
||||||
return os.getenv("REDMINE_AUTHOR", "").strip()
|
env = os.getenv("REDMINE_AUTHOR", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
if cls._app:
|
||||||
|
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:
|
||||||
|
return cls._app.output_dir
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_output_filename(cls) -> str:
|
||||||
|
if cls._app:
|
||||||
|
return cls._app.output_filename or "{author}_{from}_{to}.{ext}"
|
||||||
|
return "{author}_{from}_{to}.{ext}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_output_default_format(cls) -> str:
|
||||||
|
if cls._app:
|
||||||
|
return cls._app.output_default_format or "xlsx"
|
||||||
|
return "xlsx"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_default_date_range(cls) -> str:
|
def get_default_date_range(cls) -> str:
|
||||||
default_from_date = os.getenv("DEFAULT_FROM_DATE", "").strip()
|
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
|
||||||
default_to_date = os.getenv("DEFAULT_TO_DATE", "").strip()
|
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
|
||||||
if default_from_date and default_to_date:
|
if from_env and to_env:
|
||||||
return f"{default_from_date}--{default_to_date}"
|
return f"{from_env}--{to_env}"
|
||||||
# fallback hardcoded
|
|
||||||
return "2025-12-19--2026-01-31"
|
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 and cls._app.period_default_to:
|
||||||
|
return f"{cls._app.period_default_from}--{cls._app.period_default_to}"
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
start = today.replace(day=1)
|
||||||
|
if today.month == 12:
|
||||||
|
next_month = today.replace(year=today.year + 1, month=1, day=1)
|
||||||
|
else:
|
||||||
|
next_month = today.replace(month=today.month + 1, day=1)
|
||||||
|
end = next_month - timedelta(days=1)
|
||||||
|
return f"{start.isoformat()}--{end.isoformat()}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls) -> None:
|
def validate(cls) -> None:
|
||||||
@@ -66,3 +385,50 @@ class Config:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
|
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_next_period(last_from: str, last_to: str, precision: str) -> tuple[str, str]:
|
||||||
|
"""Compute the next report period based on the last committed period.
|
||||||
|
|
||||||
|
- For a full calendar month → next full calendar month.
|
||||||
|
- For an arbitrary range → same duration, starting the day after last_to.
|
||||||
|
- For datetime precision → same duration, starting 1 second after last_to.
|
||||||
|
"""
|
||||||
|
from datetime import datetime as dt_mod
|
||||||
|
|
||||||
|
if precision == "datetime":
|
||||||
|
fmt = "%Y-%m-%dT%H:%M:%S"
|
||||||
|
from_dt = dt_mod.fromisoformat(last_from.replace("Z", "+00:00"))
|
||||||
|
to_dt = dt_mod.fromisoformat(last_to.replace("Z", "+00:00"))
|
||||||
|
duration = to_dt - from_dt
|
||||||
|
next_from_dt = to_dt + timedelta(seconds=1)
|
||||||
|
next_to_dt = next_from_dt + duration
|
||||||
|
return next_from_dt.strftime(fmt), next_to_dt.strftime(fmt)
|
||||||
|
|
||||||
|
from_parts = last_from.split("-")
|
||||||
|
to_parts = last_to.split("-")
|
||||||
|
from_d = date(int(from_parts[0]), int(from_parts[1]), int(from_parts[2]))
|
||||||
|
to_d = date(int(to_parts[0]), int(to_parts[1]), int(to_parts[2]))
|
||||||
|
|
||||||
|
first_of_month = from_d.replace(day=1)
|
||||||
|
if from_d == first_of_month:
|
||||||
|
if to_d.month == 12:
|
||||||
|
last_of_month = date(to_d.year, 12, 31)
|
||||||
|
else:
|
||||||
|
next_first = date(to_d.year, to_d.month + 1, 1)
|
||||||
|
last_of_month = next_first - timedelta(days=1)
|
||||||
|
if to_d == last_of_month:
|
||||||
|
if to_d.month == 12:
|
||||||
|
nstart = date(to_d.year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
nstart = date(to_d.year, to_d.month + 1, 1)
|
||||||
|
if nstart.month == 12:
|
||||||
|
nend = date(nstart.year, 12, 31)
|
||||||
|
else:
|
||||||
|
nend = date(nstart.year, nstart.month + 1, 1) - timedelta(days=1)
|
||||||
|
return nstart.isoformat(), nend.isoformat()
|
||||||
|
|
||||||
|
duration = to_d - from_d
|
||||||
|
next_from = to_d + timedelta(days=1)
|
||||||
|
next_to = next_from + duration
|
||||||
|
return next_from.isoformat(), next_to.isoformat()
|
||||||
|
|||||||
0
redmine_reporter/formatters/__init__.py
Normal file
0
redmine_reporter/formatters/__init__.py
Normal file
@@ -12,13 +12,14 @@ class TableFormatter(Formatter):
|
|||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: List[ReportRow]) -> str:
|
||||||
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
|
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
time_text = r["time_text"].replace("\n", " / ")
|
||||||
table_rows.append(
|
table_rows.append(
|
||||||
[
|
[
|
||||||
r["display_project"],
|
r["display_project"],
|
||||||
r["display_version"],
|
r["display_version"],
|
||||||
f"{r['issue_id']}. {r['subject']}",
|
f"{r['issue_id']}. {r['subject']}",
|
||||||
r["status_ru"],
|
r["status_ru"],
|
||||||
r["time_text"],
|
time_text,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
|
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
|
||||||
@@ -35,9 +36,10 @@ class CompactFormatter(Formatter):
|
|||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: List[ReportRow]) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
time_text = r["time_text"].replace("\n", " / ")
|
||||||
lines.append(
|
lines.append(
|
||||||
f"{r['display_project']} | {r['display_version']} | "
|
f"{r['display_project']} | {r['display_version']} | "
|
||||||
f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {r['time_text']}"
|
f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {time_text}"
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
@@ -7,29 +7,40 @@ from .base import Formatter
|
|||||||
|
|
||||||
|
|
||||||
class CSVFormatter(Formatter):
|
class CSVFormatter(Formatter):
|
||||||
"""Форматтер для экспорта в CSV."""
|
"""Форматтер для экспорта в CSV.
|
||||||
|
|
||||||
def __init__(self, **_kwargs):
|
Использует полные значения project/version (а не display-значения с пустыми
|
||||||
|
ячейками для групп). Каждая строка CSV самодостаточна — это корректно для
|
||||||
|
табличного формата (#31). Файл сохраняется в UTF-8 с BOM (utf-8-sig) для
|
||||||
|
корректного отображения кириллицы в Microsoft Excel (#26).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, no_time: bool = False, **_kwargs):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.no_time = no_time
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: List[ReportRow]) -> str:
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
writer = csv.writer(output, dialect="excel")
|
writer = csv.writer(output, dialect="excel")
|
||||||
writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"])
|
headers = ["Project", "Version", "Issue ID", "Subject", "Status"]
|
||||||
|
if not self.no_time:
|
||||||
|
headers.append("Spent Time")
|
||||||
|
writer.writerow(headers)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
writer.writerow(
|
time_text = r["time_text"].replace("\n", " / ")
|
||||||
[
|
data = [
|
||||||
r["project"],
|
r["project"],
|
||||||
r["version"],
|
r["version"],
|
||||||
r["issue_id"],
|
r["issue_id"],
|
||||||
r["subject"],
|
r["subject"],
|
||||||
r["status_ru"],
|
r["status_ru"],
|
||||||
r["time_text"],
|
]
|
||||||
]
|
if not self.no_time:
|
||||||
)
|
data.append(time_text)
|
||||||
|
writer.writerow(data)
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||||
content = self.format(rows)
|
content = self.format(rows)
|
||||||
with open(output_path, "w", encoding="utf-8", newline="") as f:
|
with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ from .base import Formatter
|
|||||||
from .console import CompactFormatter, TableFormatter
|
from .console import CompactFormatter, TableFormatter
|
||||||
from .csv import CSVFormatter
|
from .csv import CSVFormatter
|
||||||
from .html import HTMLFormatter
|
from .html import HTMLFormatter
|
||||||
|
from .json import JSONFormatter
|
||||||
from .markdown import MarkdownFormatter
|
from .markdown import MarkdownFormatter
|
||||||
from .odt import ODTFormatter
|
from .xlsx import XLSXFormatter
|
||||||
|
|
||||||
# Словарь для сопоставления расширений файлов с классами форматтеров
|
# Словарь для сопоставления расширений файлов с классами форматтеров.
|
||||||
|
# ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
|
||||||
|
# odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
|
||||||
FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
||||||
".odt": ODTFormatter,
|
|
||||||
".csv": CSVFormatter,
|
".csv": CSVFormatter,
|
||||||
|
".json": JSONFormatter,
|
||||||
".md": MarkdownFormatter,
|
".md": MarkdownFormatter,
|
||||||
".html": HTMLFormatter,
|
".html": HTMLFormatter,
|
||||||
|
".xlsx": XLSXFormatter,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -27,10 +31,23 @@ def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]:
|
|||||||
"""
|
"""
|
||||||
Возвращает экземпляр форматтера по расширению файла.
|
Возвращает экземпляр форматтера по расширению файла.
|
||||||
Ключевые аргументы (**kwargs) передаются в конструктор форматтера.
|
Ключевые аргументы (**kwargs) передаются в конструктор форматтера.
|
||||||
|
Возвращает None для .odt, если odfpy не установлен.
|
||||||
"""
|
"""
|
||||||
formatter_class = FORMATTER_MAP.get(extension.lower())
|
ext = extension.lower()
|
||||||
|
|
||||||
|
formatter_class = FORMATTER_MAP.get(ext)
|
||||||
if formatter_class:
|
if formatter_class:
|
||||||
return formatter_class(**kwargs)
|
return formatter_class(**kwargs)
|
||||||
|
|
||||||
|
# ODT требует odfpy — ленивый импорт, чтобы отсутствие зависимости
|
||||||
|
# не ломало загрузку модуля и другие форматтеры.
|
||||||
|
if ext == ".odt":
|
||||||
|
try:
|
||||||
|
from .odt import ODTFormatter
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
return ODTFormatter(**kwargs)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from html import escape
|
from html import escape
|
||||||
from typing import Dict, List
|
from typing import List
|
||||||
|
|
||||||
|
from ..report_builder import group_rows_by_project_and_version
|
||||||
from ..types import ReportRow
|
from ..types import ReportRow
|
||||||
from .base import Formatter
|
from .base import Formatter
|
||||||
|
|
||||||
@@ -12,18 +13,15 @@ class HTMLFormatter(Formatter):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: List[ReportRow]) -> str:
|
||||||
# Сгруппируем данные
|
projects = group_rows_by_project_and_version(rows)
|
||||||
projects: Dict[str, Dict[str, List[ReportRow]]] = {}
|
|
||||||
for r in rows:
|
|
||||||
proj = r["project"]
|
|
||||||
ver = r["version"]
|
|
||||||
if proj not in projects:
|
|
||||||
projects[proj] = {}
|
|
||||||
if ver not in projects[proj]:
|
|
||||||
projects[proj][ver] = []
|
|
||||||
projects[proj][ver].append(r)
|
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
|
"<!DOCTYPE html>",
|
||||||
|
'<html lang="ru">',
|
||||||
|
"<head>",
|
||||||
|
' <meta charset="utf-8">',
|
||||||
|
"</head>",
|
||||||
|
"<body>",
|
||||||
'<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse; font-family: Arial, sans-serif;">',
|
'<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse; font-family: Arial, sans-serif;">',
|
||||||
" <thead>",
|
" <thead>",
|
||||||
" <tr>",
|
" <tr>",
|
||||||
@@ -50,7 +48,7 @@ class HTMLFormatter(Formatter):
|
|||||||
for r in task_rows:
|
for r in task_rows:
|
||||||
task_cell = escape(f"{r['issue_id']}. {r['subject']}")
|
task_cell = escape(f"{r['issue_id']}. {r['subject']}")
|
||||||
status_text = escape(r["status_ru"])
|
status_text = escape(r["status_ru"])
|
||||||
time_text = escape(r["time_text"])
|
time_text = escape(r["time_text"]).replace("\n", "<br>")
|
||||||
lines.append(" <tr>")
|
lines.append(" <tr>")
|
||||||
|
|
||||||
# Ячейка "Проект" - только в первой строке проекта
|
# Ячейка "Проект" - только в первой строке проекта
|
||||||
@@ -77,6 +75,8 @@ class HTMLFormatter(Formatter):
|
|||||||
|
|
||||||
lines.append(" </tbody>")
|
lines.append(" </tbody>")
|
||||||
lines.append("</table>")
|
lines.append("</table>")
|
||||||
|
lines.append("</body>")
|
||||||
|
lines.append("</html>")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||||
|
|||||||
34
redmine_reporter/formatters/json.py
Normal file
34
redmine_reporter/formatters/json.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import json
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ..types import ReportRow
|
||||||
|
from .base import Formatter
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(Formatter):
|
||||||
|
"""Форматтер для экспорта отчёта в JSON."""
|
||||||
|
|
||||||
|
def __init__(self, **_kwargs):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def format(self, rows: List[ReportRow]) -> str:
|
||||||
|
data = []
|
||||||
|
for r in rows:
|
||||||
|
item = {
|
||||||
|
"project": r["project"],
|
||||||
|
"version": r["version"],
|
||||||
|
"issue_id": r["issue_id"],
|
||||||
|
"subject": r["subject"],
|
||||||
|
"status": r["status_ru"],
|
||||||
|
"time": r["time_text"],
|
||||||
|
}
|
||||||
|
activities = r.get("activities")
|
||||||
|
if activities:
|
||||||
|
item["activities"] = activities
|
||||||
|
data.append(item)
|
||||||
|
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from importlib import resources
|
from importlib import resources
|
||||||
from typing import Dict, List
|
from typing import List
|
||||||
|
|
||||||
from odf.opendocument import OpenDocument, load
|
from odf.opendocument import OpenDocument, load
|
||||||
from odf.style import Style, TableCellProperties, TableColumnProperties
|
from odf.style import Style, TableCellProperties, TableColumnProperties
|
||||||
from odf.table import Table, TableCell, TableColumn, TableRow
|
from odf.table import CoveredTableCell, Table, TableCell, TableColumn, TableRow
|
||||||
from odf.text import P
|
from odf.text import P
|
||||||
|
|
||||||
|
from ..report_builder import group_rows_by_project_and_version
|
||||||
from ..types import ReportRow
|
from ..types import ReportRow
|
||||||
from ..utils import get_month_name_from_range
|
from ..utils import get_month_name_from_range
|
||||||
from .base import Formatter
|
from .base import Formatter
|
||||||
@@ -14,7 +15,7 @@ from .base import Formatter
|
|||||||
class ODTFormatter(Formatter):
|
class ODTFormatter(Formatter):
|
||||||
"""Форматтер для экспорта в ODT."""
|
"""Форматтер для экспорта в ODT."""
|
||||||
|
|
||||||
def __init__(self, author: str = "", from_date: str = "", to_date: str = ""):
|
def __init__(self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs):
|
||||||
"""
|
"""
|
||||||
Инициализирует форматтер с параметрами для шапки отчета.
|
Инициализирует форматтер с параметрами для шапки отчета.
|
||||||
"""
|
"""
|
||||||
@@ -30,11 +31,21 @@ class ODTFormatter(Formatter):
|
|||||||
with resources.files("redmine_reporter").joinpath("templates/template.odt").open("rb") as f:
|
with resources.files("redmine_reporter").joinpath("templates/template.odt").open("rb") as f:
|
||||||
doc = load(f)
|
doc = load(f)
|
||||||
|
|
||||||
|
# Удаляем все текстовые параграфы из шаблона, оставляя только
|
||||||
|
# структурные элементы (forms, sequence-decls). Это защищает от
|
||||||
|
# артефактов редактирования шаблона в LibreOffice (#42).
|
||||||
|
for child in list(doc.text.childNodes):
|
||||||
|
if child.tagName == "text:p":
|
||||||
|
doc.text.removeChild(child)
|
||||||
|
|
||||||
para_style_name = "Standard"
|
para_style_name = "Standard"
|
||||||
|
|
||||||
# Заголовок
|
# Заголовок
|
||||||
month_name = get_month_name_from_range(self.from_date, self.to_date)
|
month_name = get_month_name_from_range(self.from_date, self.to_date)
|
||||||
header_text = f"{self.author}. Отчет за месяц {month_name}."
|
if self.author:
|
||||||
|
header_text = f"{self.author}. Отчет за месяц {month_name}."
|
||||||
|
else:
|
||||||
|
header_text = f"Отчет за месяц {month_name}."
|
||||||
doc.text.addElement(P(stylename=para_style_name, text=header_text))
|
doc.text.addElement(P(stylename=para_style_name, text=header_text))
|
||||||
doc.text.addElement(P(stylename=para_style_name, text=""))
|
doc.text.addElement(P(stylename=para_style_name, text=""))
|
||||||
|
|
||||||
@@ -69,18 +80,7 @@ class ODTFormatter(Formatter):
|
|||||||
header_row.addElement(cell)
|
header_row.addElement(cell)
|
||||||
table.addElement(header_row)
|
table.addElement(header_row)
|
||||||
|
|
||||||
projects: Dict[str, Dict[str, List[ReportRow]]] = {}
|
projects = group_rows_by_project_and_version(rows)
|
||||||
for r in rows:
|
|
||||||
project = r["project"]
|
|
||||||
version = r["version"]
|
|
||||||
|
|
||||||
if project not in projects:
|
|
||||||
projects[project] = {}
|
|
||||||
|
|
||||||
if version not in projects[project]:
|
|
||||||
projects[project][version] = []
|
|
||||||
|
|
||||||
projects[project][version].append(r)
|
|
||||||
|
|
||||||
# Данные с двухуровневой группировкой и объединением ячеек
|
# Данные с двухуровневой группировкой и объединением ячеек
|
||||||
for project, versions in projects.items():
|
for project, versions in projects.items():
|
||||||
@@ -96,15 +96,19 @@ class ODTFormatter(Formatter):
|
|||||||
for r in rows_for_version:
|
for r in rows_for_version:
|
||||||
row = TableRow()
|
row = TableRow()
|
||||||
|
|
||||||
# Ячейка "Проект" - только в первой строке всего проекта
|
# Ячейка "Проект" - только в первой строке всего проекта,
|
||||||
|
# в остальных — covered-cell для валидности ODF (#13)
|
||||||
if first_version_in_project and first_row_in_version:
|
if first_version_in_project and first_row_in_version:
|
||||||
cell_project = TableCell(stylename=cell_style_name)
|
cell_project = TableCell(stylename=cell_style_name)
|
||||||
cell_project.setAttribute("numberrowsspanned", str(total_project_rows))
|
cell_project.setAttribute("numberrowsspanned", str(total_project_rows))
|
||||||
p = P(stylename=para_style_name, text=project)
|
p = P(stylename=para_style_name, text=project)
|
||||||
cell_project.addElement(p)
|
cell_project.addElement(p)
|
||||||
row.addElement(cell_project)
|
row.addElement(cell_project)
|
||||||
|
else:
|
||||||
|
row.addElement(CoveredTableCell())
|
||||||
|
|
||||||
# Ячейка "Версия" - только в первой строке каждой версии
|
# Ячейка "Версия" - только в первой строке каждой версии,
|
||||||
|
# в остальных — covered-cell для валидности ODF (#13)
|
||||||
if first_row_in_version:
|
if first_row_in_version:
|
||||||
cell_version = TableCell(stylename=cell_style_name)
|
cell_version = TableCell(stylename=cell_style_name)
|
||||||
cell_version.setAttribute("numberrowsspanned", str(row_span_version))
|
cell_version.setAttribute("numberrowsspanned", str(row_span_version))
|
||||||
@@ -112,6 +116,8 @@ class ODTFormatter(Formatter):
|
|||||||
cell_version.addElement(p)
|
cell_version.addElement(p)
|
||||||
row.addElement(cell_version)
|
row.addElement(cell_version)
|
||||||
first_row_in_version = False
|
first_row_in_version = False
|
||||||
|
else:
|
||||||
|
row.addElement(CoveredTableCell())
|
||||||
|
|
||||||
# Остальные колонки
|
# Остальные колонки
|
||||||
task_cell = TableCell(stylename=cell_style_name)
|
task_cell = TableCell(stylename=cell_style_name)
|
||||||
@@ -126,8 +132,12 @@ class ODTFormatter(Formatter):
|
|||||||
row.addElement(status_cell)
|
row.addElement(status_cell)
|
||||||
|
|
||||||
time_cell = TableCell(stylename=cell_style_name)
|
time_cell = TableCell(stylename=cell_style_name)
|
||||||
p = P(stylename=para_style_name, text=r["time_text"])
|
time_lines = r["time_text"].split("\n")
|
||||||
time_cell.addElement(p)
|
for i, line in enumerate(time_lines):
|
||||||
|
p = P(stylename=para_style_name, text=line)
|
||||||
|
time_cell.addElement(p)
|
||||||
|
if i < len(time_lines) - 1:
|
||||||
|
time_cell.addElement(P(stylename=para_style_name, text=""))
|
||||||
row.addElement(time_cell)
|
row.addElement(time_cell)
|
||||||
|
|
||||||
table.addElement(row)
|
table.addElement(row)
|
||||||
|
|||||||
214
redmine_reporter/formatters/xlsx.py
Normal file
214
redmine_reporter/formatters/xlsx.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
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
|
||||||
|
from openpyxl.worksheet.worksheet import Worksheet
|
||||||
|
|
||||||
|
from ..report_builder import group_rows_by_project_and_version
|
||||||
|
from ..types import ReportRow
|
||||||
|
from ..utils import hours_to_human
|
||||||
|
from .base import Formatter
|
||||||
|
|
||||||
|
|
||||||
|
class XLSXFormatter(Formatter):
|
||||||
|
"""Форматтер для экспорта отчёта в Excel (.xlsx).
|
||||||
|
|
||||||
|
Использует группировку по проекту и версии: объединяет ячейки,
|
||||||
|
добавляет итоги по группам, закрепляет заголовок, включает автофильтр
|
||||||
|
и числовой столбец с часами для удобного суммирования.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_HEADER_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
|
||||||
|
_TOTAL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
||||||
|
_BORDER = Border(
|
||||||
|
left=Side(style="thin"),
|
||||||
|
right=Side(style="thin"),
|
||||||
|
top=Side(style="thin"),
|
||||||
|
bottom=Side(style="thin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, no_time: bool = False, **_kwargs):
|
||||||
|
super().__init__()
|
||||||
|
self.no_time = no_time
|
||||||
|
|
||||||
|
def format(self, rows: List[ReportRow]) -> Workbook:
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
if ws is None:
|
||||||
|
ws = wb.create_sheet("Report")
|
||||||
|
else:
|
||||||
|
ws.title = "Report"
|
||||||
|
|
||||||
|
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Hours", "Spent Time"]
|
||||||
|
ws.append(headers)
|
||||||
|
self._style_header_row(ws, headers)
|
||||||
|
|
||||||
|
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] = {}
|
||||||
|
|
||||||
|
for project, versions in grouped.items():
|
||||||
|
project_start_row = current_row
|
||||||
|
|
||||||
|
for version, task_rows in versions.items():
|
||||||
|
version_start_row = current_row
|
||||||
|
|
||||||
|
for r in task_rows:
|
||||||
|
hours = "" if self.no_time else r.get("hours", 0.0)
|
||||||
|
time_text = "" if self.no_time else r["time_text"]
|
||||||
|
ws.append(
|
||||||
|
[
|
||||||
|
project,
|
||||||
|
version,
|
||||||
|
r["issue_id"],
|
||||||
|
r["subject"],
|
||||||
|
r["status_ru"],
|
||||||
|
hours,
|
||||||
|
time_text,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._style_data_row(ws, current_row)
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
if not self.no_time:
|
||||||
|
version_hours = sum(r.get("hours", 0.0) for r in task_rows)
|
||||||
|
ws.append(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
f"Total {version}",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
version_hours,
|
||||||
|
hours_to_human(version_hours),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._style_total_row(ws, current_row, bold=False)
|
||||||
|
ws.merge_cells(
|
||||||
|
start_row=current_row, start_column=2, end_row=current_row, end_column=5
|
||||||
|
)
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
version_end_row = current_row - 1
|
||||||
|
if version_end_row > version_start_row:
|
||||||
|
version_ranges.append((version_start_row, version_end_row))
|
||||||
|
|
||||||
|
if not self.no_time:
|
||||||
|
project_hours = sum(
|
||||||
|
sum(r.get("hours", 0.0) for r in task_rows) for task_rows in versions.values()
|
||||||
|
)
|
||||||
|
ws.append(
|
||||||
|
[
|
||||||
|
f"Total {project}",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
project_hours,
|
||||||
|
hours_to_human(project_hours),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._style_total_row(ws, current_row, bold=True)
|
||||||
|
ws.merge_cells(
|
||||||
|
start_row=current_row, start_column=1, end_row=current_row, end_column=5
|
||||||
|
)
|
||||||
|
current_row += 1
|
||||||
|
project_totals[project] = project_hours
|
||||||
|
|
||||||
|
project_end_row = current_row - 1
|
||||||
|
if project_end_row > project_start_row:
|
||||||
|
project_ranges.append((project_start_row, project_end_row))
|
||||||
|
|
||||||
|
if not self.no_time and project_totals:
|
||||||
|
total_hours = sum(project_totals.values())
|
||||||
|
ws.append(
|
||||||
|
[
|
||||||
|
"Total",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
total_hours,
|
||||||
|
hours_to_human(total_hours),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._style_total_row(ws, current_row, bold=True)
|
||||||
|
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=5)
|
||||||
|
|
||||||
|
for start, end in project_ranges:
|
||||||
|
ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1)
|
||||||
|
cell = ws.cell(row=start, column=1)
|
||||||
|
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||||
|
|
||||||
|
for start, end in version_ranges:
|
||||||
|
ws.merge_cells(start_row=start, start_column=2, end_row=end, end_column=2)
|
||||||
|
cell = ws.cell(row=start, column=2)
|
||||||
|
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||||
|
|
||||||
|
self._apply_column_widths(ws)
|
||||||
|
if not self.no_time:
|
||||||
|
self._apply_number_format(ws)
|
||||||
|
self._apply_auto_filter(ws, ws.max_row)
|
||||||
|
ws.freeze_panes = "A2"
|
||||||
|
|
||||||
|
return wb
|
||||||
|
|
||||||
|
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:
|
||||||
|
for col_idx, _ in enumerate(headers, start=1):
|
||||||
|
cell = ws.cell(row=1, column=col_idx)
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.fill = self._HEADER_FILL
|
||||||
|
cell.border = self._BORDER
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||||
|
|
||||||
|
def _style_data_row(self, ws: Worksheet, row: int) -> None:
|
||||||
|
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:
|
||||||
|
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||||
|
else:
|
||||||
|
cell.alignment = Alignment(vertical="top")
|
||||||
|
|
||||||
|
def _style_total_row(self, ws: Worksheet, row: int, bold: bool) -> None:
|
||||||
|
for col_idx in range(1, 8):
|
||||||
|
cell = ws.cell(row=row, column=col_idx)
|
||||||
|
cell.border = self._BORDER
|
||||||
|
cell.fill = self._TOTAL_FILL
|
||||||
|
cell.font = Font(bold=bold)
|
||||||
|
cell.alignment = Alignment(vertical="center")
|
||||||
|
|
||||||
|
def _apply_column_widths(self, ws: Worksheet) -> None:
|
||||||
|
# Минимальные ширины по умолчанию
|
||||||
|
widths: Dict[int, float] = {1: 18.0, 2: 16.0, 3: 12.0, 4: 45.0, 5: 14.0, 6: 10.0, 7: 14.0}
|
||||||
|
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
||||||
|
for col_idx, cell in enumerate(row, start=1):
|
||||||
|
if cell.value is None:
|
||||||
|
continue
|
||||||
|
text = str(cell.value)
|
||||||
|
# Оценочная ширина: примерно 1.1 символа на единицу ширины Excel
|
||||||
|
estimated = len(text) * 1.1 + 2
|
||||||
|
widths[col_idx] = max(widths[col_idx], min(estimated, 80))
|
||||||
|
|
||||||
|
for col_idx, width in widths.items():
|
||||||
|
ws.column_dimensions[get_column_letter(col_idx)].width = width
|
||||||
|
|
||||||
|
def _apply_number_format(self, ws: Worksheet) -> None:
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=6, max_col=6):
|
||||||
|
for cell in row:
|
||||||
|
if isinstance(cell.value, (int, float)):
|
||||||
|
cell.number_format = "0.00"
|
||||||
|
|
||||||
|
def _apply_auto_filter(self, ws: Worksheet, max_row: int) -> None:
|
||||||
|
ws.auto_filter.ref = f"A1:G{max_row}"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import List, Tuple, cast
|
from typing import Dict, List, Optional, Tuple, cast
|
||||||
|
|
||||||
from redminelib.resources import Issue
|
from redminelib.resources import Issue
|
||||||
|
|
||||||
@@ -22,9 +22,19 @@ STATUS_TRANSLATION = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_activities(activities: Dict[str, float]) -> str:
|
||||||
|
"""Форматирует разбивку по активностям в многострочный текст."""
|
||||||
|
if not activities:
|
||||||
|
return ""
|
||||||
|
# Сортируем по убыванию часов, затем по алфавиту для стабильности
|
||||||
|
items = sorted(activities.items(), key=lambda x: (-x[1], x[0]))
|
||||||
|
return "\n".join(f"{hours_to_human(hours)} {name}" for name, hours in items)
|
||||||
|
|
||||||
|
|
||||||
def build_grouped_report(
|
def build_grouped_report(
|
||||||
issue_hours: List[Tuple[Issue, float]],
|
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]],
|
||||||
fill_time: bool = True,
|
fill_time: bool = True,
|
||||||
|
by_activity: bool = False,
|
||||||
) -> List[ReportRow]:
|
) -> List[ReportRow]:
|
||||||
"""
|
"""
|
||||||
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
||||||
@@ -35,18 +45,26 @@ def build_grouped_report(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
||||||
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0])))
|
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id))
|
||||||
|
|
||||||
rows: List[ReportRow] = []
|
rows: List[ReportRow] = []
|
||||||
prev_project: str = ""
|
prev_project: str = ""
|
||||||
prev_version: str = ""
|
prev_version: str = ""
|
||||||
|
|
||||||
for issue, hours in issue_hours:
|
for issue, hours, *rest in issue_hours:
|
||||||
|
activities: Optional[Dict[str, float]] = rest[0] if rest else None
|
||||||
project = str(issue.project)
|
project = str(issue.project)
|
||||||
version = get_version(issue)
|
version = get_version(issue)
|
||||||
status_en = str(issue.status)
|
status_en = str(issue.status)
|
||||||
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
|
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
|
||||||
time_text = hours_to_human(hours) if fill_time else ""
|
|
||||||
|
if fill_time:
|
||||||
|
if by_activity and activities:
|
||||||
|
time_text = _format_activities(activities)
|
||||||
|
else:
|
||||||
|
time_text = hours_to_human(hours)
|
||||||
|
else:
|
||||||
|
time_text = ""
|
||||||
|
|
||||||
display_project = project if project != prev_project else ""
|
display_project = project if project != prev_project else ""
|
||||||
display_version = version if (project != prev_project or version != prev_version) else ""
|
display_version = version if (project != prev_project or version != prev_version) else ""
|
||||||
@@ -63,6 +81,8 @@ def build_grouped_report(
|
|||||||
"subject": issue.subject,
|
"subject": issue.subject,
|
||||||
"status_ru": status_ru,
|
"status_ru": status_ru,
|
||||||
"time_text": time_text,
|
"time_text": time_text,
|
||||||
|
"hours": round(hours, 2),
|
||||||
|
"activities": activities,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -71,3 +91,62 @@ def build_grouped_report(
|
|||||||
prev_version = version
|
prev_version = version
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_summary(
|
||||||
|
rows: List[ReportRow],
|
||||||
|
by_activity: bool = False,
|
||||||
|
) -> Dict[str, float]:
|
||||||
|
"""Возвращает сводку: общее время, время по проектам, версиям и активностям."""
|
||||||
|
total = 0.0
|
||||||
|
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)
|
||||||
|
total += hours
|
||||||
|
by_project[r["project"]] = by_project.get(r["project"], 0.0) + hours
|
||||||
|
key = f"{r['project']}::{r['version']}"
|
||||||
|
by_project_version[key] = by_project_version.get(key, 0.0) + hours
|
||||||
|
|
||||||
|
if by_activity:
|
||||||
|
activities = r.get("activities")
|
||||||
|
if activities:
|
||||||
|
for name, act_hours in activities.items():
|
||||||
|
by_activity_name[name] = by_activity_name.get(name, 0.0) + act_hours
|
||||||
|
|
||||||
|
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()},
|
||||||
|
}
|
||||||
|
if by_activity:
|
||||||
|
result.update({f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def group_rows_by_project_and_version(
|
||||||
|
rows: List[ReportRow],
|
||||||
|
) -> Dict[str, Dict[str, List[ReportRow]]]:
|
||||||
|
"""
|
||||||
|
Группирует плоский список строк отчёта в иерархию project → version → [rows].
|
||||||
|
|
||||||
|
Предполагается, что rows уже отсортирован (build_grouped_report это гарантирует).
|
||||||
|
Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+).
|
||||||
|
Используется форматтерами HTML и ODT для объединения ячеек.
|
||||||
|
"""
|
||||||
|
projects: Dict[str, Dict[str, List[ReportRow]]] = {}
|
||||||
|
for r in rows:
|
||||||
|
project = r["project"]
|
||||||
|
version = r["version"]
|
||||||
|
|
||||||
|
if project not in projects:
|
||||||
|
projects[project] = {}
|
||||||
|
if version not in projects[project]:
|
||||||
|
projects[project][version] = []
|
||||||
|
|
||||||
|
projects[project][version].append(r)
|
||||||
|
|
||||||
|
return projects
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import TypedDict
|
from typing import Dict, Optional, TypedDict
|
||||||
|
|
||||||
|
|
||||||
class ReportRow(TypedDict):
|
class ReportRowBase(TypedDict):
|
||||||
"""Строка итогового отчёта."""
|
"""Строка итогового отчёта."""
|
||||||
|
|
||||||
project: str
|
project: str
|
||||||
@@ -12,3 +12,10 @@ class ReportRow(TypedDict):
|
|||||||
subject: str
|
subject: str
|
||||||
status_ru: str
|
status_ru: str
|
||||||
time_text: str
|
time_text: str
|
||||||
|
hours: float
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRow(ReportRowBase, total=False):
|
||||||
|
"""Строка итогового отчёта с опциональной разбивкой по активностям."""
|
||||||
|
|
||||||
|
activities: Optional[Dict[str, float]]
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ def get_version(issue) -> str:
|
|||||||
version = getattr(issue, "fixed_version", None)
|
version = getattr(issue, "fixed_version", None)
|
||||||
if version is None:
|
if version is None:
|
||||||
return "<N/A>"
|
return "<N/A>"
|
||||||
return str(version)
|
name = getattr(version, "name", None)
|
||||||
|
return str(name) if name else str(version)
|
||||||
|
|
||||||
|
|
||||||
def hours_to_human(hours: float) -> str:
|
def hours_to_human(hours: float) -> str:
|
||||||
|
|||||||
186
redmine_reporter/yaml_config.py
Normal file
186
redmine_reporter/yaml_config.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_env_vars(value: str) -> str:
|
||||||
|
"""Replace ${VAR} patterns with environment variable values.
|
||||||
|
|
||||||
|
Non-recursive: if ${A} expands to a literal ${B}, ${B} is NOT resolved.
|
||||||
|
Unknown variables are replaced with empty string and a warning is logged.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _replacer(match: re.Match) -> str:
|
||||||
|
var_name = match.group(1)
|
||||||
|
env_value = os.environ.get(var_name)
|
||||||
|
if env_value is None:
|
||||||
|
logger.warning("Environment variable %s is not set, using empty string", var_name)
|
||||||
|
return ""
|
||||||
|
return env_value
|
||||||
|
|
||||||
|
return _ENV_VAR_RE.sub(_replacer, value)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_config_dir(path: Path | None = None) -> Path:
|
||||||
|
"""Create ~/.config/redmine-reporter/ with 0o700 permissions.
|
||||||
|
|
||||||
|
Returns the path to the config directory.
|
||||||
|
No-op if the directory already exists.
|
||||||
|
"""
|
||||||
|
if path is None:
|
||||||
|
path = Path.home() / ".config" / "redmine-reporter"
|
||||||
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def check_file_permissions(path: Path) -> list[str]:
|
||||||
|
"""Check that a config file has safe permissions (0o600 or stricter).
|
||||||
|
|
||||||
|
Returns a list of warning messages. Empty list means no issues.
|
||||||
|
"""
|
||||||
|
warnings: list[str] = []
|
||||||
|
if not path.exists():
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
mode = path.stat().st_mode & 0o777
|
||||||
|
if mode > 0o600:
|
||||||
|
warnings.append(
|
||||||
|
f"Config file permissions are too open ({mode:04o}). "
|
||||||
|
f"Expected 0600. Fix with: chmod 600 {path}"
|
||||||
|
)
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def expand_filename_template(
|
||||||
|
template: str,
|
||||||
|
*,
|
||||||
|
author: str = "",
|
||||||
|
from_date: str = "",
|
||||||
|
to_date: str = "",
|
||||||
|
ext: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Expand placeholders in a filename template.
|
||||||
|
|
||||||
|
Supported placeholders:
|
||||||
|
{author} — author name
|
||||||
|
{from} — start date (YYYY-MM-DD)
|
||||||
|
{to} — end date (YYYY-MM-DD)
|
||||||
|
{date} — end date formatted as DD_MM_YYYY
|
||||||
|
{ext} — file extension without dot
|
||||||
|
|
||||||
|
Unknown placeholders are left as-is.
|
||||||
|
"""
|
||||||
|
date_dd_mm_yyyy = ""
|
||||||
|
if to_date:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
dt = datetime.strptime(to_date, "%Y-%m-%d")
|
||||||
|
date_dd_mm_yyyy = dt.strftime("%d_%m_%Y")
|
||||||
|
except ValueError:
|
||||||
|
date_dd_mm_yyyy = to_date
|
||||||
|
|
||||||
|
replacements = {
|
||||||
|
"author": author.replace(" ", "_"),
|
||||||
|
"from": from_date,
|
||||||
|
"to": to_date,
|
||||||
|
"date": date_dd_mm_yyyy,
|
||||||
|
"ext": ext,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = template
|
||||||
|
for key, value in replacements.items():
|
||||||
|
result = result.replace("{" + key + "}", value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
KNOWN_FORMAT_NAMES = {"xlsx", "odt", "csv", "md", "html", "json"}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_output_path(
|
||||||
|
output_arg: str | None,
|
||||||
|
*,
|
||||||
|
output_dir: str = "",
|
||||||
|
filename_template: str = "{author}_{from}_{to}.{ext}",
|
||||||
|
default_format: str = "xlsx",
|
||||||
|
author: str = "",
|
||||||
|
from_date: str = "",
|
||||||
|
to_date: str = "",
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve the final output path from --output argument and config settings.
|
||||||
|
|
||||||
|
Returns None if output_arg is None (meaning no file output, use console).
|
||||||
|
|
||||||
|
Resolution rules:
|
||||||
|
- None → None (console output)
|
||||||
|
- Bare format name (xlsx, odt, csv, md, html, json) → use template path
|
||||||
|
- Path without extension → append .{default_format}
|
||||||
|
- Path with known extension → use as-is
|
||||||
|
- Path with unknown extension → use as-is
|
||||||
|
"""
|
||||||
|
if output_arg is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
arg = output_arg.strip()
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
if arg.lower() in KNOWN_FORMAT_NAMES:
|
||||||
|
ext = arg.lower()
|
||||||
|
resolved_dir = os.path.expanduser(output_dir) if output_dir else "."
|
||||||
|
filename = expand_filename_template(
|
||||||
|
filename_template,
|
||||||
|
author=author,
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
ext=ext,
|
||||||
|
)
|
||||||
|
return os.path.join(resolved_dir, filename)
|
||||||
|
|
||||||
|
_, ext = os.path.splitext(arg)
|
||||||
|
if not ext:
|
||||||
|
arg = f"{arg}.{default_format}"
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
path = Path(config_path)
|
||||||
|
raw: dict = {}
|
||||||
|
if path.exists():
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
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
|
||||||
|
|
||||||
|
ensure_config_dir(path.parent)
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
yaml.dump(raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
||||||
|
path.chmod(0o600)
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
from io import StringIO
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -13,6 +11,20 @@ VALID_ENV = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_config_overrides():
|
||||||
|
"""Сбрасывает CLI-переопределения Config перед каждым тестом."""
|
||||||
|
from redmine_reporter.config import Config
|
||||||
|
|
||||||
|
Config.set_redmine_url("")
|
||||||
|
Config.set_redmine_api_key("")
|
||||||
|
Config._app = None
|
||||||
|
yield
|
||||||
|
Config.set_redmine_url("")
|
||||||
|
Config.set_redmine_api_key("")
|
||||||
|
Config._app = None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"date_arg, expected",
|
"date_arg, expected",
|
||||||
[
|
[
|
||||||
@@ -38,21 +50,6 @@ def test_parse_date_range_invalid(date_arg):
|
|||||||
parse_date_range(date_arg)
|
parse_date_range(date_arg)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
|
||||||
def test_cli_smoke_empty(mock_fetch):
|
|
||||||
"""Пустой список задач -- выход 0, сообщение о 0 задачах."""
|
|
||||||
mock_fetch.return_value = []
|
|
||||||
captured = StringIO()
|
|
||||||
old_stdout, sys.stdout = sys.stdout, captured
|
|
||||||
try:
|
|
||||||
code = main(["--date", "2026-01-01--2026-01-31"])
|
|
||||||
finally:
|
|
||||||
sys.stdout = old_stdout
|
|
||||||
assert code == 0
|
|
||||||
assert "Total issues: 0" in captured.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
def test_cli_returns_zero_on_no_entries(mock_fetch):
|
def test_cli_returns_zero_on_no_entries(mock_fetch):
|
||||||
@@ -65,7 +62,7 @@ def test_cli_returns_zero_on_no_entries(mock_fetch):
|
|||||||
@mock.patch.dict(os.environ, {}, clear=True)
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
def test_cli_config_error():
|
def test_cli_config_error():
|
||||||
"""Невалидный конфиг -- выход 1."""
|
"""Невалидный конфиг -- выход 1."""
|
||||||
code = main(["--date", "2026-01-01--2026-01-31"])
|
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", "/nonexistent/config.yml"])
|
||||||
assert code == 1
|
assert code == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -76,11 +73,23 @@ def test_cli_invalid_date_format():
|
|||||||
assert code == 1
|
assert code == 1
|
||||||
|
|
||||||
|
|
||||||
|
class _MockIssue:
|
||||||
|
"""Простой mock Redmine Issue для CLI-тестов."""
|
||||||
|
|
||||||
|
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
|
||||||
|
self.id = issue_id
|
||||||
|
self.subject = subject
|
||||||
|
self.project = project
|
||||||
|
self.status = status
|
||||||
|
self.fixed_version = None
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
||||||
"""Неизвестное расширение файла -- выход 1."""
|
"""Неизвестное расширение файла -- выход 1."""
|
||||||
mock_fetch.return_value = []
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
output = str(tmp_path / "report.xyz")
|
output = str(tmp_path / "report.xyz")
|
||||||
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
||||||
assert code == 1
|
assert code == 1
|
||||||
@@ -88,9 +97,671 @@ def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
|||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
def test_cli_output_without_extension(mock_fetch, tmp_path):
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
"""Файл без расширения -- выход 1 с подсказкой."""
|
def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
|
||||||
mock_fetch.return_value = []
|
"""Файл без расширения — приложение расширяет default_format (#43)."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
output = str(tmp_path / "report")
|
output = str(tmp_path / "report")
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-01-01--2026-01-31",
|
||||||
|
"--output",
|
||||||
|
output,
|
||||||
|
"--config-path",
|
||||||
|
str(tmp_path / "nonexistent.yml"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
saved_path = mock_formatter.save.call_args.args[1]
|
||||||
|
assert saved_path.endswith(".xlsx")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
|
def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
|
||||||
|
"""При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
mock_gf.return_value = None
|
||||||
|
output = str(tmp_path / "report.odt")
|
||||||
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
||||||
assert code == 1
|
assert code == 1
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "odfpy" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
def test_cli_version_flag(capsys):
|
||||||
|
"""--version выводит версию и завершается с 0."""
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
main(["--version"])
|
||||||
|
assert exc_info.value.code == 0
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "redmine-reporter" in captured.out
|
||||||
|
from redmine_reporter import __version__
|
||||||
|
|
||||||
|
assert __version__ in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_verbose_and_debug_flags_accepted(mock_fetch):
|
||||||
|
"""--verbose и --debug не ломают запуск."""
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31", "--verbose"])
|
||||||
|
assert code == 0
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31", "--debug"])
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
|
||||||
|
class _MockIssue:
|
||||||
|
"""Простой mock Redmine Issue для CLI-тестов."""
|
||||||
|
|
||||||
|
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
|
||||||
|
self.id = issue_id
|
||||||
|
self.subject = subject
|
||||||
|
self.project = project
|
||||||
|
self.status = status
|
||||||
|
self.fixed_version = None
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_url_and_api_key_override_env(mock_fetch):
|
||||||
|
"""--url и --api-key переопределяют отсутствующие/другие env-переменные."""
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-01-01--2026-01-31",
|
||||||
|
"--url",
|
||||||
|
"https://other.redmine.loc",
|
||||||
|
"--api-key",
|
||||||
|
"cli-token",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_config_file_loading(mock_fetch, tmp_path):
|
||||||
|
"""--config загружает переменные из указанного .env-файла."""
|
||||||
|
config_path = tmp_path / "custom.env"
|
||||||
|
config_path.write_text(
|
||||||
|
"REDMINE_URL=https://config.redmine.loc\n" "REDMINE_API_KEY=config-token\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31", "--config", str(config_path)])
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
|
||||||
|
"""--summary выводит общее время и разбивку по проектам в stderr."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
main(["--date", "2026-01-01--2026-01-31", "--summary"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Total time" in captured.err
|
||||||
|
assert "Project" in captured.err or "Project" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
|
||||||
|
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
main(["--date", "2026-01-01--2026-01-31"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Total issues" not in captured.out
|
||||||
|
assert "Total issues" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_prints_readable_auth_error(mock_fetch, capsys):
|
||||||
|
"""CLI выводит понятное сообщение при ошибке аутентификации."""
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert code == 1
|
||||||
|
assert "Authentication failed" in captured.err
|
||||||
|
assert "Redmine API error" not in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_prints_readable_forbidden_error(mock_fetch, capsys):
|
||||||
|
"""CLI выводит понятное сообщение при недостаточных правах."""
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
mock_fetch.side_effect = RedmineAPIError("Access denied: no permission")
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert code == 1
|
||||||
|
assert "Access denied" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
|
||||||
|
"""CLI выводит понятное сообщение при таймауте."""
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
mock_fetch.side_effect = RedmineAPIError("Redmine request timed out after 30 seconds")
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert code == 1
|
||||||
|
assert "timed out" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
|
||||||
|
"""CLI --no-time передаётся в файловый форматтер."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
|
output = str(tmp_path / "report.xlsx")
|
||||||
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get_formatter:
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get_formatter.return_value = mock_formatter
|
||||||
|
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
|
||||||
|
|
||||||
|
_, kwargs = mock_get_formatter.call_args
|
||||||
|
assert kwargs.get("no_time") is True
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_passes_user_id_to_fetch(mock_fetch):
|
||||||
|
"""CLI --user-id передаётся в fetch_issues_with_spent_time."""
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
main(["--date", "2026-01-01--2026-01-31", "--user-id", "42"])
|
||||||
|
_, kwargs = mock_fetch.call_args
|
||||||
|
assert kwargs["user_id"] == "42"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_passes_user_login_to_fetch(mock_fetch):
|
||||||
|
"""CLI --user-login передаётся в fetch_issues_with_spent_time."""
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
main(["--date", "2026-01-01--2026-01-31", "--user-login", "ivanov"])
|
||||||
|
_, kwargs = mock_fetch.call_args
|
||||||
|
assert kwargs["user_id"] == "ivanov"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
def test_cli_rejects_multiple_user_flags():
|
||||||
|
"""CLI не принимает одновременно несколько флагов пользователя."""
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-01-01--2026-01-31",
|
||||||
|
"--user-id",
|
||||||
|
"42",
|
||||||
|
"--user-login",
|
||||||
|
"ivanov",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --init-config tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestInitConfig:
|
||||||
|
"""Tests for --init-config flag."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
def test_init_config_creates_yaml_file(self, tmp_path):
|
||||||
|
"""--init-config создаёт YAML файл с правильной структурой."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
code = main(["--init-config", "--config-path", str(config_path)])
|
||||||
|
assert code == 0
|
||||||
|
assert config_path.exists()
|
||||||
|
|
||||||
|
with open(config_path) as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
assert "redmine" in data
|
||||||
|
assert data["redmine"]["url"] == "https://red.eltex.loc"
|
||||||
|
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
def test_init_config_existing_file_no_force(self, tmp_path, capsys):
|
||||||
|
"""Без --force существующий файл → предупреждение и выход 1."""
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text("existing: true")
|
||||||
|
|
||||||
|
code = main(["--init-config", "--config-path", str(config_path)])
|
||||||
|
assert code == 1
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "already exists" in captured.err or "already exists" in captured.out
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
def test_init_config_force_overwrites(self, tmp_path):
|
||||||
|
"""--init-config --force перезаписывает существующий файл."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text("existing: true")
|
||||||
|
|
||||||
|
code = main(["--init-config", "--force", "--config-path", str(config_path)])
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
with open(config_path) as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
assert "redmine" in data
|
||||||
|
assert "existing" not in data
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
def test_init_config_with_date_is_error(self, capsys):
|
||||||
|
"""--init-config с --date → ошибка, взаимоисключающие."""
|
||||||
|
code = main(["--init-config", "--date", "2026-01-01--2026-01-31"])
|
||||||
|
assert code == 1
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
def test_init_config_writes_secrets_as_env_ref(self, tmp_path):
|
||||||
|
"""Секреты пишутся как ${VAR} когда переменная окружения существует."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
code = main(["--init-config", "--config-path", str(config_path)])
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
with open(config_path) as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_init_config_no_env_vars(self, tmp_path):
|
||||||
|
"""Без переменных окружения --init-config создаёт скелет."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
code = main(["--init-config", "--config-path", str(config_path)])
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
with open(config_path) as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
assert "redmine" in data
|
||||||
|
assert data["redmine"]["url"] == ""
|
||||||
|
assert data["redmine"]["api_key"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_autoloads_yaml_config(mock_fetch, tmp_path):
|
||||||
|
"""CLI автоматически подгружает YAML-конфиг при запуске."""
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"redmine:\n url: https://yaml-redmine.example.com/\n api_key: yaml-token\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)])
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #43: --output path resolution tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutputPathResolution:
|
||||||
|
"""Tests for --output flag with path defaults."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_bare_format_name_xlsx_uses_template_path(self, mock_fetch, tmp_path):
|
||||||
|
"""--output xlsx без пути → использует шаблон из конфига."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
yaml.dump(
|
||||||
|
{
|
||||||
|
"output": {
|
||||||
|
"dir": str(tmp_path / "reports"),
|
||||||
|
"filename": "report_{date}.{ext}",
|
||||||
|
"default_format": "xlsx",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-01-01--2026-01-31",
|
||||||
|
"--output",
|
||||||
|
"xlsx",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_output_without_extension_appends_default_format(self, mock_fetch, tmp_path):
|
||||||
|
"""--output report без расширения → добавляет .xlsx."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
|
output = str(tmp_path / "report")
|
||||||
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-01-01--2026-01-31",
|
||||||
|
"--output",
|
||||||
|
output,
|
||||||
|
"--config-path",
|
||||||
|
str(tmp_path / "nonexistent.yml"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_formatter.save.assert_called_once()
|
||||||
|
saved_path = mock_formatter.save.call_args.args[1]
|
||||||
|
assert saved_path.endswith(".xlsx")
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path):
|
||||||
|
"""--output report.csv с явным расширением передаётся как есть."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-01-01--2026-01-31",
|
||||||
|
"--output",
|
||||||
|
str(tmp_path / "report.csv"),
|
||||||
|
"--config-path",
|
||||||
|
str(tmp_path / "nonexistent.yml"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_formatter.save.assert_called_once()
|
||||||
|
saved_path = mock_formatter.save.call_args.args[1]
|
||||||
|
assert saved_path.endswith(".csv")
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_output_bare_format_invalid_is_treated_as_path(self, mock_fetch, tmp_path):
|
||||||
|
"""--output notanxlsx (no path, not known format) → treated as path + .xlsx appended."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-01-01--2026-01-31",
|
||||||
|
"--output",
|
||||||
|
"myreport",
|
||||||
|
"--config-path",
|
||||||
|
str(tmp_path / "nonexistent.yml"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_formatter.save.assert_called_once()
|
||||||
|
saved_path = mock_formatter.save.call_args.args[1]
|
||||||
|
assert saved_path == "myreport.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #44: --commit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommitFlag:
|
||||||
|
"""Tests for --commit flag."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
|
def test_commit_saves_period_to_config(self, mock_save, mock_fetch, tmp_path):
|
||||||
|
"""--commit сохраняет период в YAML-конфиг."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--commit",
|
||||||
|
"--output",
|
||||||
|
str(tmp_path / "report.xlsx"),
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
mock_save.assert_called_once_with(
|
||||||
|
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
|
def test_commit_with_precision_datetime_passes_datetime_strings(
|
||||||
|
self, mock_save, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""При precision=datetime --commit сохраняет timestamps."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(yaml.dump({"period": {"precision": "datetime", "dynamic": True}}))
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-30--2026-06-30",
|
||||||
|
"--commit",
|
||||||
|
"--output",
|
||||||
|
str(tmp_path / "report.xlsx"),
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
call_args = mock_save.call_args
|
||||||
|
assert call_args is not None
|
||||||
|
saved_from, saved_to = call_args.args[1], call_args.args[2]
|
||||||
|
assert "T" in saved_from
|
||||||
|
assert "T" in saved_to
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
|
||||||
|
"""Без записей --commit не пишет конфиг."""
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text("period:\n precision: date\n dynamic: true\n")
|
||||||
|
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-01-01--2026-01-31",
|
||||||
|
"--commit",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
|
def test_commit_dynamic_false_overwrites_defaults(self, mock_save, mock_fetch, tmp_path):
|
||||||
|
"""При dynamic=false --commit перезаписывает default_from/to."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": False}}))
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--commit",
|
||||||
|
"--output",
|
||||||
|
str(tmp_path / "report.xlsx"),
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
mock_save.assert_called_once_with(
|
||||||
|
str(config_path), "2026-06-01", "2026-06-30", "date", False
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
|
def test_commit_without_output_uses_default_path(self, mock_save, mock_fetch, tmp_path):
|
||||||
|
"""--commit без --output сохраняет файл по шаблону из конфига."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
yaml.dump(
|
||||||
|
{
|
||||||
|
"period": {"precision": "date", "dynamic": True},
|
||||||
|
"output": {
|
||||||
|
"dir": str(tmp_path / "reports"),
|
||||||
|
"filename": "report_{date}.{ext}",
|
||||||
|
"default_format": "xlsx",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--commit",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_formatter.save.assert_called_once()
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
|
def test_commit_prints_info_to_stderr(self, mock_save, mock_fetch, capsys, tmp_path):
|
||||||
|
"""--commit выводит сообщение о фиксации в stderr."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
|
"--commit",
|
||||||
|
"--output",
|
||||||
|
str(tmp_path / "report.xlsx"),
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "commit" in captured.err.lower() or "Commit" in captured.err
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
|
def test_commit_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path):
|
||||||
|
"""Пустой список задач — --commit не сохраняет конфиг."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
mock_fetch.return_value = []
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
|
||||||
|
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-01-01--2026-01-31",
|
||||||
|
"--commit",
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
mock_save.assert_not_called()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
from unittest import mock
|
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
|
||||||
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
|
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
issue, total_hours = result[0]
|
issue, total_hours, _activities = result[0]
|
||||||
assert total_hours == 3.5
|
assert total_hours == 3.5
|
||||||
|
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ def test_fetch_multiple_issues(mock_redmine_class):
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
|
|
||||||
hours_by_id = {issue.id: hours for issue, hours in result}
|
hours_by_id = {issue.id: hours for issue, hours, _ in result}
|
||||||
assert hours_by_id[1] == 1.5
|
assert hours_by_id[1] == 1.5
|
||||||
assert hours_by_id[2] == 2.0
|
assert hours_by_id[2] == 2.0
|
||||||
|
|
||||||
@@ -133,7 +135,7 @@ def test_fetch_uses_api_key_when_present(mock_redmine_class):
|
|||||||
|
|
||||||
_, kwargs = mock_redmine_class.call_args
|
_, kwargs = mock_redmine_class.call_args
|
||||||
assert kwargs["key"] == "api-token"
|
assert kwargs["key"] == "api-token"
|
||||||
assert kwargs["requests"] == {"verify": DEFAULT_REDMINE_VERIFY}
|
assert kwargs["requests"]["verify"] == DEFAULT_REDMINE_VERIFY
|
||||||
assert "username" not in kwargs
|
assert "username" not in kwargs
|
||||||
assert "password" not in kwargs
|
assert "password" not in kwargs
|
||||||
|
|
||||||
@@ -151,7 +153,7 @@ def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
|
|||||||
_, kwargs = mock_redmine_class.call_args
|
_, kwargs = mock_redmine_class.call_args
|
||||||
assert kwargs["username"] == "user"
|
assert kwargs["username"] == "user"
|
||||||
assert kwargs["password"] == "password"
|
assert kwargs["password"] == "password"
|
||||||
assert kwargs["requests"] == {"verify": DEFAULT_REDMINE_VERIFY}
|
assert kwargs["requests"]["verify"] == DEFAULT_REDMINE_VERIFY
|
||||||
assert "key" not in kwargs
|
assert "key" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
@@ -165,4 +167,507 @@ def test_fetch_uses_custom_verify_path(mock_redmine_class):
|
|||||||
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
_, kwargs = mock_redmine_class.call_args
|
_, kwargs = mock_redmine_class.call_args
|
||||||
assert kwargs["requests"] == {"verify": "/tmp/redmine-ca.pem"}
|
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_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
|
||||||
|
"""AuthError превращается в RedmineAPIError с понятным сообщением."""
|
||||||
|
from redminelib.exceptions import AuthError
|
||||||
|
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
mock_redmine.user.get.side_effect = AuthError()
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="Authentication failed"):
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_raises_redmine_api_error_on_forbidden(mock_redmine_class):
|
||||||
|
"""ForbiddenError превращается в RedmineAPIError с понятным сообщением."""
|
||||||
|
from redminelib.exceptions import ForbiddenError
|
||||||
|
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
mock_redmine.user.get.side_effect = ForbiddenError()
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="Access denied"):
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_raises_redmine_api_error_on_timeout(mock_redmine_class):
|
||||||
|
"""requests Timeout превращается в RedmineAPIError с понятным сообщением."""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
mock_redmine.user.get.side_effect = requests.exceptions.Timeout("timeout")
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="timed out"):
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_raises_redmine_api_error_on_connection_error(mock_redmine_class):
|
||||||
|
"""ConnectionError превращается в RedmineAPIError с понятным сообщением."""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
mock_redmine.user.get.side_effect = requests.exceptions.ConnectionError("no route")
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="Cannot connect"):
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_raises_redmine_api_error_on_http_500(mock_redmine_class):
|
||||||
|
"""HTTP 500 превращается в RedmineAPIError с понятным сообщением."""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
response = requests.Response()
|
||||||
|
response.status_code = 500
|
||||||
|
mock_redmine.user.get.side_effect = requests.exceptions.HTTPError(
|
||||||
|
"server error", response=response
|
||||||
|
)
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="server error"):
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_uses_specified_user_id(mock_redmine_class):
|
||||||
|
"""user_id передаётся в time_entry.filter вместо текущего пользователя."""
|
||||||
|
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", user_id=42)
|
||||||
|
|
||||||
|
_, kwargs = mock_redmine.time_entry.filter.call_args
|
||||||
|
assert kwargs["user_id"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_resolves_user_login_to_id(mock_redmine_class):
|
||||||
|
"""Строковый логин разрешается в числовой ID."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
mock_user = mock.MagicMock()
|
||||||
|
mock_user.id = 7
|
||||||
|
mock_redmine.user.filter.return_value = [mock_user]
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
|
||||||
|
|
||||||
|
login_filter_call = mock_redmine.user.filter.call_args_list[0]
|
||||||
|
assert login_filter_call.kwargs == {"login": "ivanov"}
|
||||||
|
|
||||||
|
_, kwargs = mock_redmine.time_entry.filter.call_args
|
||||||
|
assert kwargs["user_id"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_resolves_user_name_to_id(mock_redmine_class):
|
||||||
|
"""Строковое имя разрешается в числовой ID, если найден ровно один пользователь."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
mock_redmine.user.filter.side_effect = [
|
||||||
|
[], # поиск по логину не дал результатов
|
||||||
|
[mock.MagicMock(id=9)], # поиск по имени нашёл одного
|
||||||
|
]
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="Иванов И.И.")
|
||||||
|
|
||||||
|
name_filter_call = mock_redmine.user.filter.call_args_list[1]
|
||||||
|
assert name_filter_call.kwargs == {"name": "Иванов И.И."}
|
||||||
|
|
||||||
|
_, kwargs = mock_redmine.time_entry.filter.call_args
|
||||||
|
assert kwargs["user_id"] == 9
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_rejects_ambiguous_user_name(mock_redmine_class):
|
||||||
|
"""Если по имени найдено несколько пользователей — ошибка с понятным сообщением."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
mock_redmine.user.filter.side_effect = [
|
||||||
|
[], # поиск по логину не дал результатов
|
||||||
|
[mock.MagicMock(id=1), mock.MagicMock(id=2)], # по имени — несколько
|
||||||
|
]
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="Multiple users match"):
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="Иванов")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_rejects_unknown_user_login(mock_redmine_class):
|
||||||
|
"""Если пользователь не найден — ошибка с предложением использовать --user-id."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
mock_redmine.user.filter.return_value = []
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="not found"):
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="unknown")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_numeric_string_user_id(mock_redmine_class):
|
||||||
|
"""Строка из цифр интерпретируется как числовой ID."""
|
||||||
|
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", user_id="42")
|
||||||
|
|
||||||
|
assert mock_redmine.user.filter.call_count == 0
|
||||||
|
_, kwargs = mock_redmine.time_entry.filter.call_args
|
||||||
|
assert kwargs["user_id"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
# -- #24: Таймаут и retry --
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_sets_timeout_in_requests(mock_redmine_class):
|
||||||
|
"""В requests dict передаётся timeout (#24)."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
_, kwargs = mock_redmine_class.call_args
|
||||||
|
assert "timeout" in kwargs["requests"]
|
||||||
|
assert kwargs["requests"]["timeout"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_mounts_retry_adapter(mock_redmine_class):
|
||||||
|
"""На сессию монтируется HTTPAdapter с retry для временных ошибок (#24)."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Проверяем, что engine.session.mount был вызван для http:// и https://
|
||||||
|
mount_calls = mock_redmine.engine.session.mount.call_args_list
|
||||||
|
prefixes = [call.args[0] for call in mount_calls]
|
||||||
|
assert "https://" in prefixes
|
||||||
|
assert "http://" in prefixes
|
||||||
|
|
||||||
|
# Проверяем retry-конфигурацию адаптера
|
||||||
|
https_adapter = next(call.args[1] for call in mount_calls if call.args[0] == "https://")
|
||||||
|
max_retries = https_adapter.max_retries
|
||||||
|
assert max_retries.total == 3
|
||||||
|
assert 429 in max_retries.status_forcelist
|
||||||
|
|
||||||
|
|
||||||
|
# -- #21: Чанкирование issue_ids --
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_chunks_large_issue_count(mock_redmine_class):
|
||||||
|
"""При >100 задач запросы разбиваются на чанки (#21)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
# 250 time entries → 250 уникальных issue_id
|
||||||
|
entries = []
|
||||||
|
for i in range(1, 251):
|
||||||
|
e = mock.MagicMock()
|
||||||
|
e.issue.id = i
|
||||||
|
e.hours = 1.0
|
||||||
|
entries.append(e)
|
||||||
|
mock_redmine.time_entry.filter.return_value = entries
|
||||||
|
|
||||||
|
# issue.filter вызывается с чанками по 100 ID
|
||||||
|
call_chunks = []
|
||||||
|
|
||||||
|
def issue_filter_side_effect(**kwargs):
|
||||||
|
ids_str = kwargs.get("issue_id", "")
|
||||||
|
call_chunks.append(ids_str)
|
||||||
|
ids = [int(x) for x in ids_str.split(",")]
|
||||||
|
return [mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids]
|
||||||
|
|
||||||
|
mock_redmine.issue.filter.side_effect = issue_filter_side_effect
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
# Должно быть 3 вызова (100 + 100 + 50)
|
||||||
|
assert len(call_chunks) == 3
|
||||||
|
assert len(call_chunks[0].split(",")) == 100
|
||||||
|
assert len(call_chunks[1].split(",")) == 100
|
||||||
|
assert len(call_chunks[2].split(",")) == 50
|
||||||
|
assert result is not None
|
||||||
|
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}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY, Config
|
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY, AppConfig, Config
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(
|
@mock.patch.dict(
|
||||||
@@ -74,9 +76,19 @@ def test_get_default_date_range_from_env():
|
|||||||
|
|
||||||
@mock.patch.dict(os.environ, {}, clear=True)
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
def test_get_default_date_range_fallback():
|
def test_get_default_date_range_fallback():
|
||||||
"""Если даты не заданы -- используется хардкод-заглушка."""
|
"""Если даты не заданы -- используется текущий месяц."""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
start = today.replace(day=1)
|
||||||
|
if today.month == 12:
|
||||||
|
next_month = today.replace(year=today.year + 1, month=1, day=1)
|
||||||
|
else:
|
||||||
|
next_month = today.replace(month=today.month + 1, day=1)
|
||||||
|
end = next_month - timedelta(days=1)
|
||||||
|
|
||||||
result = Config.get_default_date_range()
|
result = Config.get_default_date_range()
|
||||||
assert "--" in result # формат YYYY-MM-DD--YYYY-MM-DD
|
assert result == f"{start.isoformat()}--{end.isoformat()}"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, {}, clear=True)
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
@@ -99,3 +111,449 @@ def test_get_redmine_verify_true_values(value):
|
|||||||
@mock.patch.dict(os.environ, {"REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True)
|
@mock.patch.dict(os.environ, {"REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True)
|
||||||
def test_get_redmine_verify_custom_path():
|
def test_get_redmine_verify_custom_path():
|
||||||
assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem"
|
assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem"
|
||||||
|
|
||||||
|
|
||||||
|
# -- #15: .env не должен переопределять переменные окружения --
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("dotenv.load_dotenv")
|
||||||
|
def test_env_var_takes_priority_over_dotenv(mock_load):
|
||||||
|
"""load_dotenv вызывается с override=False — env vars не перебиваются .env."""
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from redmine_reporter import config as cfg_mod
|
||||||
|
|
||||||
|
importlib.reload(cfg_mod)
|
||||||
|
|
||||||
|
mock_load.assert_called_once_with(override=False)
|
||||||
|
|
||||||
|
|
||||||
|
# -- #35: get_redmine_password должен делать .strip() --
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"REDMINE_PASSWORD": " secret123 "}, clear=True)
|
||||||
|
def test_get_redmine_password_strips_whitespace():
|
||||||
|
"""Пароль обрезается от whitespace, как и все остальные геттеры."""
|
||||||
|
assert Config.get_redmine_password() == "secret123"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AppConfig tests — YAML config loading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
FULL_YAML = """\
|
||||||
|
redmine:
|
||||||
|
url: https://redmine.example.com/
|
||||||
|
api_key: ${MISSING_API_KEY_FOR_TEST}
|
||||||
|
author: "Тестовый Автор"
|
||||||
|
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"
|
||||||
|
|
||||||
|
output:
|
||||||
|
dir: ~/reports
|
||||||
|
filename: "{author}_{from}_{to}.{ext}"
|
||||||
|
default_format: xlsx
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp:
|
||||||
|
host: smtp.example.com
|
||||||
|
port: 587
|
||||||
|
user: bot@example.com
|
||||||
|
password: ${MISSING_SMTP_PASSWORD_FOR_TEST}
|
||||||
|
tls: true
|
||||||
|
from: bot@example.com
|
||||||
|
to:
|
||||||
|
- boss@example.com
|
||||||
|
cc: []
|
||||||
|
bcc: []
|
||||||
|
subject: "Отчёт {author} за {period}"
|
||||||
|
body_text: "Во вложении отчёт."
|
||||||
|
attach: true
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppConfigFromYaml:
|
||||||
|
"""Tests for AppConfig.from_yaml()."""
|
||||||
|
|
||||||
|
def test_loads_all_sections_from_valid_yaml(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(FULL_YAML)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.redmine_url == "https://redmine.example.com/"
|
||||||
|
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.output_dir == "~/reports"
|
||||||
|
assert cfg.output_filename == "{author}_{from}_{to}.{ext}"
|
||||||
|
assert cfg.output_default_format == "xlsx"
|
||||||
|
assert cfg.email.smtp.host == "smtp.example.com"
|
||||||
|
assert cfg.email.smtp.port == 587
|
||||||
|
assert cfg.email.smtp.user == "bot@example.com"
|
||||||
|
assert cfg.email.smtp.password == ""
|
||||||
|
assert cfg.email.smtp.tls is True
|
||||||
|
assert cfg.email.from_ == "bot@example.com"
|
||||||
|
assert cfg.email.to == ["boss@example.com"]
|
||||||
|
assert cfg.email.subject == "Отчёт {author} за {period}"
|
||||||
|
|
||||||
|
def test_partial_yaml_uses_defaults_for_missing(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("redmine:\n url: https://x.com/\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.redmine_url == "https://x.com/"
|
||||||
|
# defaults for everything else
|
||||||
|
assert cfg.redmine_author == ""
|
||||||
|
assert cfg.period_precision == "date"
|
||||||
|
assert cfg.output_dir == ""
|
||||||
|
assert cfg.email.to == []
|
||||||
|
|
||||||
|
def test_empty_file_returns_all_defaults(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.redmine_url == ""
|
||||||
|
assert cfg.redmine_api_key == ""
|
||||||
|
assert cfg.period_precision == "date"
|
||||||
|
|
||||||
|
def test_file_not_found_returns_all_defaults(self):
|
||||||
|
cfg = AppConfig.from_yaml("/nonexistent/path/config.yml")
|
||||||
|
assert cfg.redmine_url == ""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"REDMINE_API_KEY": "secret-token"}, clear=True)
|
||||||
|
def test_env_var_resolved_in_yaml(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("redmine:\n api_key: ${REDMINE_API_KEY}\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.redmine_api_key == "secret-token"
|
||||||
|
|
||||||
|
def test_verify_ssl_true_returns_default_ca_path(self):
|
||||||
|
"""verify_ssl: true → DEFAULT_REDMINE_VERIFY (путь), не Python True."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("redmine:\n verify_ssl: true\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.redmine_verify == DEFAULT_REDMINE_VERIFY
|
||||||
|
assert cfg.redmine_verify is not True # не бул!
|
||||||
|
|
||||||
|
def test_verify_ssl_false_returns_false(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("redmine:\n verify_ssl: false\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.redmine_verify is False
|
||||||
|
|
||||||
|
def test_missing_env_var_logs_warning(self, caplog):
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("redmine:\n api_key: ${MISSING_KEY}\n")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.redmine_api_key == ""
|
||||||
|
assert "MISSING_KEY" in caplog.text
|
||||||
|
|
||||||
|
def test_unknown_top_level_key_logs_warning(self, caplog):
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("unknown_section:\n foo: bar\n")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert "unknown_section" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config priority chain tests — YAML fallback
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigYamlFallback:
|
||||||
|
"""Tests that Config.get_*() falls back to YAML when env/CLI not set."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_url_from_yaml_when_no_env_or_cli(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n")
|
||||||
|
|
||||||
|
Config._cli_url = None
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert Config.get_redmine_url() == "https://yaml-redmine.example.com"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_cli_beats_yaml(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n")
|
||||||
|
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
Config.set_redmine_url("https://cli-override.example.com")
|
||||||
|
|
||||||
|
assert Config.get_redmine_url() == "https://cli-override.example.com"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True)
|
||||||
|
def test_env_beats_yaml(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n")
|
||||||
|
|
||||||
|
Config._cli_url = None
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert Config.get_redmine_url() == "https://env-redmine.example.com"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_date_range_from_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)
|
||||||
|
|
||||||
|
assert Config.get_default_date_range() == "2026-03-01--2026-03-15"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_author_from_yaml(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("redmine:\n author: 'Автор Из Ямла'\n")
|
||||||
|
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert Config.get_author("") == "Автор Из Ямла"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_validate_passes_with_yaml_url_and_api_key(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"redmine:\n url: https://yaml.example.com/\n api_key: yaml-token\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
Config._cli_url = None
|
||||||
|
Config._cli_api_key = None
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
# Не должно быть исключения
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
@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"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import io
|
import io
|
||||||
|
import json
|
||||||
from typing import List
|
from typing import List
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -7,9 +8,12 @@ from odf.opendocument import OpenDocument, OpenDocumentText
|
|||||||
|
|
||||||
from redmine_reporter.formatters.console import CompactFormatter, TableFormatter
|
from redmine_reporter.formatters.console import CompactFormatter, TableFormatter
|
||||||
from redmine_reporter.formatters.csv import CSVFormatter
|
from redmine_reporter.formatters.csv import CSVFormatter
|
||||||
|
from redmine_reporter.formatters.factory import get_formatter_by_extension
|
||||||
from redmine_reporter.formatters.html import HTMLFormatter
|
from redmine_reporter.formatters.html import HTMLFormatter
|
||||||
|
from redmine_reporter.formatters.json import JSONFormatter
|
||||||
from redmine_reporter.formatters.markdown import MarkdownFormatter
|
from redmine_reporter.formatters.markdown import MarkdownFormatter
|
||||||
from redmine_reporter.formatters.odt import ODTFormatter
|
from redmine_reporter.formatters.odt import ODTFormatter
|
||||||
|
from redmine_reporter.formatters.xlsx import XLSXFormatter
|
||||||
from redmine_reporter.types import ReportRow
|
from redmine_reporter.types import ReportRow
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +42,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Реализовать фичу X",
|
"subject": "Реализовать фичу X",
|
||||||
"status_ru": "В работе",
|
"status_ru": "В работе",
|
||||||
"time_text": "4ч 30м",
|
"time_text": "4ч 30м",
|
||||||
|
"hours": 4.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект A",
|
"project": "Проект A",
|
||||||
@@ -48,6 +53,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Исправить баг Y",
|
"subject": "Исправить баг Y",
|
||||||
"status_ru": "Решена",
|
"status_ru": "Решена",
|
||||||
"time_text": "2ч",
|
"time_text": "2ч",
|
||||||
|
"hours": 2.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект A",
|
"project": "Проект A",
|
||||||
@@ -58,6 +64,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Документация Z",
|
"subject": "Документация Z",
|
||||||
"status_ru": "Ожидание",
|
"status_ru": "Ожидание",
|
||||||
"time_text": "1ч",
|
"time_text": "1ч",
|
||||||
|
"hours": 1.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект B",
|
"project": "Проект B",
|
||||||
@@ -68,6 +75,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Обновить README",
|
"subject": "Обновить README",
|
||||||
"status_ru": "Закрыто",
|
"status_ru": "Закрыто",
|
||||||
"time_text": "0ч",
|
"time_text": "0ч",
|
||||||
|
"hours": 0.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект C",
|
"project": "Проект C",
|
||||||
@@ -78,6 +86,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Настроить CI",
|
"subject": "Настроить CI",
|
||||||
"status_ru": "В работе",
|
"status_ru": "В работе",
|
||||||
"time_text": "3ч 15м",
|
"time_text": "3ч 15м",
|
||||||
|
"hours": 3.25,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект C",
|
"project": "Проект C",
|
||||||
@@ -88,6 +97,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Добавить тесты",
|
"subject": "Добавить тесты",
|
||||||
"status_ru": "В работе",
|
"status_ru": "В работе",
|
||||||
"time_text": "5ч",
|
"time_text": "5ч",
|
||||||
|
"hours": 5.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект C",
|
"project": "Проект C",
|
||||||
@@ -98,6 +108,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Рефакторинг",
|
"subject": "Рефакторинг",
|
||||||
"status_ru": "Решена",
|
"status_ru": "Решена",
|
||||||
"time_text": "6ч 45м",
|
"time_text": "6ч 45м",
|
||||||
|
"hours": 6.75,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -114,8 +125,6 @@ def odt_formatter():
|
|||||||
mock_file = mock.MagicMock()
|
mock_file = mock.MagicMock()
|
||||||
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
|
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
|
||||||
mock_file.__exit__ = mock.MagicMock(return_value=False)
|
mock_file.__exit__ = mock.MagicMock(return_value=False)
|
||||||
mock_path = mock.MagicMock()
|
|
||||||
mock_path.open.return_value = mock_file
|
|
||||||
|
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"redmine_reporter.formatters.odt.resources.files",
|
"redmine_reporter.formatters.odt.resources.files",
|
||||||
@@ -128,12 +137,104 @@ def odt_formatter():
|
|||||||
yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31")
|
yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Тесты упаковки formatters как полноценного пакета --
|
||||||
|
|
||||||
|
|
||||||
|
def test_formatters_is_regular_package():
|
||||||
|
"""redmine_reporter.formatters — полноценный пакет с __init__.py, не namespace."""
|
||||||
|
import redmine_reporter.formatters
|
||||||
|
|
||||||
|
assert hasattr(redmine_reporter.formatters, "__file__")
|
||||||
|
assert redmine_reporter.formatters.__file__.endswith("__init__.py")
|
||||||
|
|
||||||
|
|
||||||
|
def test_formatters_found_by_setuptools():
|
||||||
|
"""setuptools.find_packages находит formatters как полноценный пакет."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from setuptools import find_packages
|
||||||
|
|
||||||
|
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
found = find_packages(where=project_root, include=["redmine_reporter*"])
|
||||||
|
assert "redmine_reporter.formatters" in found
|
||||||
|
|
||||||
|
|
||||||
|
# -- Тесты ленивого импорта ODT (#25) --
|
||||||
|
|
||||||
|
|
||||||
|
def _simulate_missing_odfpy():
|
||||||
|
"""Вспомогательная функция: подготавливает очистку кэша модулей odf и odt.
|
||||||
|
|
||||||
|
Возвращает словарь сохранённых модулей для последующего восстановления.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
saved = {}
|
||||||
|
for key in list(sys.modules.keys()):
|
||||||
|
if key == "odf" or key.startswith("odf.") or key == "redmine_reporter.formatters.odt":
|
||||||
|
saved[key] = sys.modules.pop(key)
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_modules(saved):
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.modules.update(saved)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_formatter_odt_returns_none_without_odfpy():
|
||||||
|
"""Без odfpy — get_formatter_by_extension('.odt') возвращает None, не падает."""
|
||||||
|
import builtins
|
||||||
|
|
||||||
|
from redmine_reporter.formatters.factory import get_formatter_by_extension
|
||||||
|
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def blocking_import(name, *args, **kwargs):
|
||||||
|
if name == "odf" or name.startswith("odf."):
|
||||||
|
raise ImportError(f"No module named '{name}'")
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
saved = _simulate_missing_odfpy()
|
||||||
|
try:
|
||||||
|
with mock.patch("builtins.__import__", side_effect=blocking_import):
|
||||||
|
result = get_formatter_by_extension(
|
||||||
|
".odt", author="test", from_date="2026-01-01", to_date="2026-01-31"
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
finally:
|
||||||
|
_restore_modules(saved)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_formatter_csv_works_without_odfpy():
|
||||||
|
"""Без odfpy — get_formatter_by_extension('.csv') работает нормально."""
|
||||||
|
import builtins
|
||||||
|
|
||||||
|
from redmine_reporter.formatters.factory import get_formatter_by_extension
|
||||||
|
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def blocking_import(name, *args, **kwargs):
|
||||||
|
if name == "odf" or name.startswith("odf."):
|
||||||
|
raise ImportError(f"No module named '{name}'")
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
saved = _simulate_missing_odfpy()
|
||||||
|
try:
|
||||||
|
with mock.patch("builtins.__import__", side_effect=blocking_import):
|
||||||
|
result = get_formatter_by_extension(".csv")
|
||||||
|
assert result is not None
|
||||||
|
finally:
|
||||||
|
_restore_modules(saved)
|
||||||
|
|
||||||
|
|
||||||
# -- Параметризованные тесты текстовых форматтеров --
|
# -- Параметризованные тесты текстовых форматтеров --
|
||||||
|
|
||||||
TEXT_FORMATTER_FACTORIES = [
|
TEXT_FORMATTER_FACTORIES = [
|
||||||
("table", lambda: TableFormatter()),
|
("table", lambda: TableFormatter()),
|
||||||
("compact", lambda: CompactFormatter()),
|
("compact", lambda: CompactFormatter()),
|
||||||
("csv", lambda: CSVFormatter()),
|
("csv", lambda: CSVFormatter()),
|
||||||
|
("json", lambda: JSONFormatter()),
|
||||||
("markdown", lambda: MarkdownFormatter()),
|
("markdown", lambda: MarkdownFormatter()),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -161,6 +262,10 @@ def test_text_formatter_contains_key_content(fake_rows, name, factory):
|
|||||||
# В CSV issue_id и subject -- отдельные колонки
|
# В CSV issue_id и subject -- отдельные колонки
|
||||||
assert "101" in output
|
assert "101" in output
|
||||||
assert "Реализовать фичу X" in output
|
assert "Реализовать фичу X" in output
|
||||||
|
elif name == "json":
|
||||||
|
# В JSON issue_id -- число, subject -- отдельное поле
|
||||||
|
assert '"issue_id": 101' in output
|
||||||
|
assert "Реализовать фичу X" in output
|
||||||
else:
|
else:
|
||||||
assert "101. Реализовать фичу X" in output
|
assert "101. Реализовать фичу X" in output
|
||||||
|
|
||||||
@@ -185,6 +290,134 @@ def test_compact_formatter_save_raises(fake_rows):
|
|||||||
CompactFormatter().save(fake_rows, "/dev/null")
|
CompactFormatter().save(fake_rows, "/dev/null")
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_save_writes_utf8_bom(fake_rows, tmp_path):
|
||||||
|
"""CSV-файл начинается с UTF-8 BOM для корректного открытия в Excel (#26)."""
|
||||||
|
output = tmp_path / "report.csv"
|
||||||
|
CSVFormatter().save(fake_rows, str(output))
|
||||||
|
content = output.read_bytes()
|
||||||
|
assert content[:3] == b"\xef\xbb\xbf" # UTF-8 BOM
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_uses_full_values_not_display(fake_rows):
|
||||||
|
"""CSV экспортирует полные project/version, а не display-значения (#31).
|
||||||
|
|
||||||
|
В отличие от консольных и Markdown форматтеров (display_*), CSV содержит
|
||||||
|
полные значения в каждой строке — это корректно для табличного формата.
|
||||||
|
"""
|
||||||
|
output = CSVFormatter().format(fake_rows)
|
||||||
|
lines = output.strip().split("\n")
|
||||||
|
# Header + 7 data rows = 8 lines
|
||||||
|
assert len(lines) == 8
|
||||||
|
# Вторая строка данных (lines[2]) имеет display_project="" и display_version="",
|
||||||
|
# но CSV должен содержать полные значения
|
||||||
|
assert "Проект A" in lines[2]
|
||||||
|
assert "v1.0" in lines[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_save_writes_parsable_data(fake_rows, tmp_path):
|
||||||
|
"""JSON-файл содержит валидный JSON со всеми строками отчёта."""
|
||||||
|
output = tmp_path / "report.json"
|
||||||
|
JSONFormatter().save(fake_rows, str(output))
|
||||||
|
data = json.loads(output.read_text(encoding="utf-8"))
|
||||||
|
assert len(data) == len(fake_rows)
|
||||||
|
assert data[0]["project"] == "Проект A"
|
||||||
|
assert data[0]["issue_id"] == 101
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_formatter_by_extension_json():
|
||||||
|
"""get_formatter_by_extension('.json') возвращает JSONFormatter."""
|
||||||
|
formatter = get_formatter_by_extension(".json")
|
||||||
|
assert isinstance(formatter, JSONFormatter)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_formatter_by_extension_xlsx():
|
||||||
|
"""get_formatter_by_extension('.xlsx') возвращает XLSXFormatter."""
|
||||||
|
formatter = get_formatter_by_extension(".xlsx")
|
||||||
|
assert isinstance(formatter, XLSXFormatter)
|
||||||
|
|
||||||
|
|
||||||
|
def test_xlsx_save_creates_valid_file(fake_rows, tmp_path):
|
||||||
|
"""XLSX-файл сохраняется и содержит корректные данные."""
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
output = tmp_path / "report.xlsx"
|
||||||
|
XLSXFormatter().save(fake_rows, str(output))
|
||||||
|
|
||||||
|
wb = load_workbook(str(output))
|
||||||
|
ws = wb.active
|
||||||
|
assert ws.title == "Report"
|
||||||
|
assert ws["A1"].value == "Project"
|
||||||
|
assert ws["A2"].value == "Проект A"
|
||||||
|
assert ws["C2"].value == 101
|
||||||
|
assert ws["F2"].value == 4.5
|
||||||
|
assert ws["G2"].value == "4ч 30м"
|
||||||
|
# header + 7 data rows + 5 version totals + 3 project totals + 1 grand total
|
||||||
|
assert ws.max_row == 17
|
||||||
|
|
||||||
|
|
||||||
|
def test_xlsx_has_merged_cells(fake_rows, tmp_path):
|
||||||
|
"""XLSX содержит объединённые ячейки по проектам и версиям."""
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
output = tmp_path / "report.xlsx"
|
||||||
|
XLSXFormatter().save(fake_rows, str(output))
|
||||||
|
|
||||||
|
wb = load_workbook(str(output))
|
||||||
|
ws = wb.active
|
||||||
|
merged_ranges = [str(r) for r in ws.merged_cells.ranges]
|
||||||
|
# Проект A: 3 строки данных + 2 итога по версиям + 1 итог по проекту = строки 2-7
|
||||||
|
assert any("A2:A7" in r for r in merged_ranges)
|
||||||
|
# Версия v1.0 проекта A: 2 строки данных + 1 итог по версии = строки 2-4
|
||||||
|
assert any("B2:B4" in r for r in merged_ranges)
|
||||||
|
|
||||||
|
|
||||||
|
def test_xlsx_has_totals(fake_rows, tmp_path):
|
||||||
|
"""XLSX содержит итоги по версиям, проектам и общий итог."""
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
output = tmp_path / "report.xlsx"
|
||||||
|
XLSXFormatter().save(fake_rows, str(output))
|
||||||
|
|
||||||
|
wb = load_workbook(str(output))
|
||||||
|
ws = wb.active
|
||||||
|
|
||||||
|
total_values = [ws.cell(row=r, column=6).value for r in range(2, ws.max_row + 1)]
|
||||||
|
# Проект A всего: 4.5 + 2 + 1 = 7.5
|
||||||
|
assert 7.5 in total_values
|
||||||
|
# Общий итог: 4.5 + 2 + 1 + 0 + 3.25 + 5 + 6.75 = 22.5
|
||||||
|
assert 22.5 in total_values
|
||||||
|
# Проверим числовой формат
|
||||||
|
assert ws["F2"].number_format == "0.00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_xlsx_no_time_keeps_columns_empty_and_skips_totals(fake_rows, tmp_path):
|
||||||
|
"""XLSX с no_time: колонки времени пустые, итогов нет."""
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
output = tmp_path / "report.xlsx"
|
||||||
|
XLSXFormatter(no_time=True).save(fake_rows, str(output))
|
||||||
|
|
||||||
|
wb = load_workbook(str(output))
|
||||||
|
ws = wb.active
|
||||||
|
|
||||||
|
# header + 7 data rows
|
||||||
|
assert ws.max_row == 8
|
||||||
|
|
||||||
|
# Колонки времени пустые для всех строк данных
|
||||||
|
for row in range(2, ws.max_row + 1):
|
||||||
|
assert ws.cell(row=row, column=6).value in (None, "")
|
||||||
|
assert ws.cell(row=row, column=7).value in (None, "")
|
||||||
|
|
||||||
|
# Итоговых строк нет
|
||||||
|
for row in range(2, ws.max_row + 1):
|
||||||
|
assert not str(ws.cell(row=row, column=1).value or "").startswith("Total")
|
||||||
|
assert not str(ws.cell(row=row, column=2).value or "").startswith("Total")
|
||||||
|
|
||||||
|
# Автофильтр и freeze panes на месте
|
||||||
|
assert ws.freeze_panes == "A2"
|
||||||
|
assert ws.auto_filter.ref == "A1:G8"
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_formatter_escapes_table_cells():
|
def test_markdown_formatter_escapes_table_cells():
|
||||||
rows = make_fake_report_rows()
|
rows = make_fake_report_rows()
|
||||||
rows[0]["project"] = "A|B"
|
rows[0]["project"] = "A|B"
|
||||||
@@ -210,6 +443,13 @@ def test_html_formatter_escapes_cells():
|
|||||||
assert "Fix <tag>" not in output
|
assert "Fix <tag>" not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_output_has_doctype_and_charset(fake_rows):
|
||||||
|
"""HTML-отчёт содержит DOCTYPE и meta charset для корректной кодировки (#27)."""
|
||||||
|
output = HTMLFormatter().format(fake_rows)
|
||||||
|
assert "<!DOCTYPE html>" in output
|
||||||
|
assert '<meta charset="utf-8">' in output
|
||||||
|
|
||||||
|
|
||||||
# -- Тесты ODT форматтера --
|
# -- Тесты ODT форматтера --
|
||||||
|
|
||||||
|
|
||||||
@@ -219,6 +459,33 @@ def test_odt_formatter_returns_opendocument(fake_rows, odt_formatter):
|
|||||||
assert isinstance(result, OpenDocument)
|
assert isinstance(result, OpenDocument)
|
||||||
|
|
||||||
|
|
||||||
|
def test_odt_empty_author_no_garbage_in_header(fake_rows):
|
||||||
|
"""При пустом авторе заголовок не содержит мусорных символов (#30)."""
|
||||||
|
odt_bytes = _make_empty_odt_bytes()
|
||||||
|
mock_file = mock.MagicMock()
|
||||||
|
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
|
||||||
|
mock_file.__exit__ = mock.MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"redmine_reporter.formatters.odt.resources.files",
|
||||||
|
return_value=mock.MagicMock(
|
||||||
|
joinpath=mock.MagicMock(
|
||||||
|
return_value=mock.MagicMock(open=mock.MagicMock(return_value=mock_file))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
formatter = ODTFormatter(author="", from_date="2026-01-01", to_date="2026-01-31")
|
||||||
|
doc = formatter.format(fake_rows)
|
||||||
|
|
||||||
|
from odf.text import P
|
||||||
|
|
||||||
|
paragraphs = doc.text.getElementsByType(P)
|
||||||
|
header_text = paragraphs[0].firstChild.data
|
||||||
|
|
||||||
|
assert not header_text.startswith(".")
|
||||||
|
assert "Отчет за месяц" in header_text
|
||||||
|
|
||||||
|
|
||||||
def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
|
def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
|
||||||
"""ODT можно сохранить -- файл валиден (сигнатура ZIP)."""
|
"""ODT можно сохранить -- файл валиден (сигнатура ZIP)."""
|
||||||
odt_bytes = _make_empty_odt_bytes()
|
odt_bytes = _make_empty_odt_bytes()
|
||||||
@@ -240,3 +507,40 @@ def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
|
|||||||
|
|
||||||
assert output_file.exists()
|
assert output_file.exists()
|
||||||
assert output_file.read_bytes()[:2] == b"PK" # сигнатура ZIP
|
assert output_file.read_bytes()[:2] == b"PK" # сигнатура ZIP
|
||||||
|
|
||||||
|
|
||||||
|
def test_odt_has_covered_cells_for_spans(fake_rows):
|
||||||
|
"""ODT содержит covered-table-cell для замещённых ячеек при объединении (#13).
|
||||||
|
|
||||||
|
Тестовые данные (fake_rows):
|
||||||
|
Проект A: v1.0(2 задачи), v2.0(1) → project span=3
|
||||||
|
row1: project+version (0 covered)
|
||||||
|
row2: covered project + covered version (2)
|
||||||
|
row3: covered project + new version cell (1)
|
||||||
|
Проект B: <N/A>(1) → span=1, нет covered
|
||||||
|
Проект C: v1.0(1), v1.1(2) → project span=3
|
||||||
|
row5: project+version (0 covered)
|
||||||
|
row6: covered project + new version cell (1)
|
||||||
|
row7: covered project + covered version (2)
|
||||||
|
Итого: 6 covered cells
|
||||||
|
"""
|
||||||
|
odt_bytes = _make_empty_odt_bytes()
|
||||||
|
mock_file = mock.MagicMock()
|
||||||
|
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
|
||||||
|
mock_file.__exit__ = mock.MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"redmine_reporter.formatters.odt.resources.files",
|
||||||
|
return_value=mock.MagicMock(
|
||||||
|
joinpath=mock.MagicMock(
|
||||||
|
return_value=mock.MagicMock(open=mock.MagicMock(return_value=mock_file))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
|
||||||
|
doc = formatter.format(fake_rows)
|
||||||
|
|
||||||
|
from odf.table import CoveredTableCell
|
||||||
|
|
||||||
|
covered_cells = doc.getElementsByType(CoveredTableCell)
|
||||||
|
assert len(covered_cells) == 6
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
from redmine_reporter.report_builder import STATUS_TRANSLATION, build_grouped_report
|
from redmine_reporter.report_builder import (
|
||||||
|
STATUS_TRANSLATION,
|
||||||
|
build_grouped_report,
|
||||||
|
calculate_summary,
|
||||||
|
group_rows_by_project_and_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MockIssue:
|
class MockIssue:
|
||||||
@@ -56,7 +61,61 @@ def test_build_grouped_report_grouping():
|
|||||||
|
|
||||||
assert rows[0]["status_ru"] == "В работе"
|
assert rows[0]["status_ru"] == "В работе"
|
||||||
assert rows[0]["time_text"] == "2ч"
|
assert rows[0]["time_text"] == "2ч"
|
||||||
|
assert rows[0]["hours"] == 2.0
|
||||||
assert rows[1]["time_text"] == "1ч 30м"
|
assert rows[1]["time_text"] == "1ч 30м"
|
||||||
|
assert rows[1]["hours"] == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
# -- #22: Сводка по времени --
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_summary_totals():
|
||||||
|
"""Сводка содержит общее время и разбивку по проектам/версиям."""
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"project": "Камеры",
|
||||||
|
"version": "v2.5.0",
|
||||||
|
"issue_id": 101,
|
||||||
|
"subject": "Фича A",
|
||||||
|
"status_ru": "В работе",
|
||||||
|
"time_text": "2ч",
|
||||||
|
"hours": 2.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "Камеры",
|
||||||
|
"version": "v2.5.0",
|
||||||
|
"issue_id": 102,
|
||||||
|
"subject": "Баг B",
|
||||||
|
"status_ru": "Решена",
|
||||||
|
"time_text": "1ч 30м",
|
||||||
|
"hours": 1.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "ПО",
|
||||||
|
"version": "<N/A>",
|
||||||
|
"issue_id": 201,
|
||||||
|
"subject": "Доки",
|
||||||
|
"status_ru": "Ожидание",
|
||||||
|
"time_text": "4ч",
|
||||||
|
"hours": 4.0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
summary = calculate_summary(rows)
|
||||||
|
|
||||||
|
assert summary["total"] == 7.5
|
||||||
|
assert summary["project:Камеры"] == 3.5
|
||||||
|
assert summary["project:ПО"] == 4.0
|
||||||
|
assert summary["version:Камеры::v2.5.0"] == 3.5
|
||||||
|
assert summary["version:ПО::<N/A>"] == 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_summary_empty():
|
||||||
|
"""Сводка для пустого списка -- только total = 0."""
|
||||||
|
summary = calculate_summary([])
|
||||||
|
assert summary == {"total": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
# -- #19: Общая функция группировки --
|
||||||
|
|
||||||
|
|
||||||
def test_build_grouped_report_new_version_same_project():
|
def test_build_grouped_report_new_version_same_project():
|
||||||
@@ -105,3 +164,116 @@ def test_build_grouped_report_preserves_issue_id_and_subject():
|
|||||||
rows = build_grouped_report(issues)
|
rows = build_grouped_report(issues)
|
||||||
assert rows[0]["issue_id"] == 42
|
assert rows[0]["issue_id"] == 42
|
||||||
assert rows[0]["subject"] == "Моя задача"
|
assert rows[0]["subject"] == "Моя задача"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_grouped_report_deterministic_order_within_group():
|
||||||
|
"""Задачи в одной группе проект+версия упорядочены по issue.id
|
||||||
|
независимо от порядка на входе (#33)."""
|
||||||
|
issues = [
|
||||||
|
(MockIssue("P", "Task C", "New", "v1.0", 103), 1.0),
|
||||||
|
(MockIssue("P", "Task A", "New", "v1.0", 101), 1.0),
|
||||||
|
(MockIssue("P", "Task B", "New", "v1.0", 102), 1.0),
|
||||||
|
]
|
||||||
|
rows = build_grouped_report(issues)
|
||||||
|
assert [r["issue_id"] for r in rows] == [101, 102, 103]
|
||||||
|
|
||||||
|
|
||||||
|
# -- #19: Общая функция группировки --
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_rows_basic():
|
||||||
|
"""Два проекта, в одном две версии — структура корректна."""
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"project": "A",
|
||||||
|
"version": "v1",
|
||||||
|
"issue_id": 1,
|
||||||
|
"subject": "T1",
|
||||||
|
"status_ru": "S",
|
||||||
|
"time_text": "1ч",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "A",
|
||||||
|
"version": "v2",
|
||||||
|
"issue_id": 2,
|
||||||
|
"subject": "T2",
|
||||||
|
"status_ru": "S",
|
||||||
|
"time_text": "2ч",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "B",
|
||||||
|
"version": "v1",
|
||||||
|
"issue_id": 3,
|
||||||
|
"subject": "T3",
|
||||||
|
"status_ru": "S",
|
||||||
|
"time_text": "3ч",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
grouped = group_rows_by_project_and_version(rows)
|
||||||
|
|
||||||
|
assert list(grouped.keys()) == ["A", "B"]
|
||||||
|
assert list(grouped["A"].keys()) == ["v1", "v2"]
|
||||||
|
assert list(grouped["B"].keys()) == ["v1"]
|
||||||
|
assert len(grouped["A"]["v1"]) == 1
|
||||||
|
assert len(grouped["A"]["v2"]) == 1
|
||||||
|
assert len(grouped["B"]["v1"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_rows_multiple_tasks_per_version():
|
||||||
|
"""Несколько задач в одной версии — все попадают в список."""
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"project": "A",
|
||||||
|
"version": "v1",
|
||||||
|
"issue_id": 1,
|
||||||
|
"subject": "T1",
|
||||||
|
"status_ru": "S",
|
||||||
|
"time_text": "1ч",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "A",
|
||||||
|
"version": "v1",
|
||||||
|
"issue_id": 2,
|
||||||
|
"subject": "T2",
|
||||||
|
"status_ru": "S",
|
||||||
|
"time_text": "2ч",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project": "A",
|
||||||
|
"version": "v1",
|
||||||
|
"issue_id": 3,
|
||||||
|
"subject": "T3",
|
||||||
|
"status_ru": "S",
|
||||||
|
"time_text": "3ч",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
grouped = group_rows_by_project_and_version(rows)
|
||||||
|
|
||||||
|
assert len(grouped["A"]["v1"]) == 3
|
||||||
|
assert [r["issue_id"] for r in grouped["A"]["v1"]] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_rows_preserves_row_data():
|
||||||
|
"""Строки в группировке — те же объекты, без потери данных."""
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"project": "P",
|
||||||
|
"version": "v1",
|
||||||
|
"issue_id": 42,
|
||||||
|
"subject": "Task",
|
||||||
|
"status_ru": "Готово",
|
||||||
|
"time_text": "5ч",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
grouped = group_rows_by_project_and_version(rows)
|
||||||
|
|
||||||
|
row = grouped["P"]["v1"][0]
|
||||||
|
assert row["issue_id"] == 42
|
||||||
|
assert row["subject"] == "Task"
|
||||||
|
assert row["status_ru"] == "Готово"
|
||||||
|
assert row["time_text"] == "5ч"
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_rows_empty():
|
||||||
|
"""Пустой список — пустой словарь."""
|
||||||
|
assert group_rows_by_project_and_version([]) == {}
|
||||||
|
|||||||
@@ -78,3 +78,35 @@ def test_get_version_none_attribute():
|
|||||||
fixed_version = None
|
fixed_version = None
|
||||||
|
|
||||||
assert get_version(MockIssue()) == "<N/A>"
|
assert get_version(MockIssue()) == "<N/A>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_with_redminelib_version_object():
|
||||||
|
"""redminelib Version: str() возвращает ID, .name — человекочитаемое имя."""
|
||||||
|
|
||||||
|
class MockVersion:
|
||||||
|
"""Имитирует redminelib.resources.Version — str() даёт ID."""
|
||||||
|
|
||||||
|
def __init__(self, vid, name):
|
||||||
|
self.id = vid
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.id)
|
||||||
|
|
||||||
|
class MockIssue:
|
||||||
|
fixed_version = MockVersion(42, "v2.5.0")
|
||||||
|
|
||||||
|
assert get_version(MockIssue()) == "v2.5.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_falls_back_to_str_when_no_name():
|
||||||
|
"""Если у объекта версии нет .name — fallback на str()."""
|
||||||
|
|
||||||
|
class MockVersionNoName:
|
||||||
|
def __str__(self):
|
||||||
|
return "fallback-id"
|
||||||
|
|
||||||
|
class MockIssue:
|
||||||
|
fixed_version = MockVersionNoName()
|
||||||
|
|
||||||
|
assert get_version(MockIssue()) == "fallback-id"
|
||||||
|
|||||||
320
tests/test_yaml_config.py
Normal file
320
tests/test_yaml_config.py
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from redmine_reporter.yaml_config import (
|
||||||
|
check_file_permissions,
|
||||||
|
ensure_config_dir,
|
||||||
|
expand_filename_template,
|
||||||
|
resolve_env_vars,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveEnvVars:
|
||||||
|
"""Tests for ${VAR} resolution."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"MY_VAR": "hello"}, clear=True)
|
||||||
|
def test_replaces_var_with_env_value(self):
|
||||||
|
assert resolve_env_vars("prefix_${MY_VAR}_suffix") == "prefix_hello_suffix"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_missing_var_returns_empty_and_warns(self, caplog):
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
result = resolve_env_vars("${MISSING_VAR}")
|
||||||
|
assert result == ""
|
||||||
|
assert "MISSING_VAR" in caplog.text
|
||||||
|
|
||||||
|
def test_no_braces_returns_unchanged(self):
|
||||||
|
assert resolve_env_vars("plain text no vars") == "plain text no vars"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"A": "${B}", "B": "final"}, clear=True)
|
||||||
|
def test_non_recursive_no_double_resolution(self):
|
||||||
|
# A → literal "${B}", B → "final". Non-recursive means we get "${B}", not "final".
|
||||||
|
result = resolve_env_vars("${A}")
|
||||||
|
assert result == "${B}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureConfigDir:
|
||||||
|
"""Tests for config directory creation."""
|
||||||
|
|
||||||
|
def test_creates_dir_with_0700(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config_dir = Path(tmp) / "test_config"
|
||||||
|
result = ensure_config_dir(config_dir)
|
||||||
|
assert result == config_dir
|
||||||
|
assert result.exists()
|
||||||
|
assert result.is_dir()
|
||||||
|
mode = result.stat().st_mode & 0o777
|
||||||
|
assert mode == 0o700
|
||||||
|
|
||||||
|
def test_noop_if_dir_exists(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config_dir = Path(tmp) / "existing"
|
||||||
|
config_dir.mkdir(mode=0o700)
|
||||||
|
mtime_before = config_dir.stat().st_mtime
|
||||||
|
result = ensure_config_dir(config_dir)
|
||||||
|
assert result == config_dir
|
||||||
|
assert config_dir.stat().st_mtime == mtime_before
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckFilePermissions:
|
||||||
|
"""Tests for config file permission checks."""
|
||||||
|
|
||||||
|
def test_warns_on_permissive_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
f = Path(tmp) / "open.yml"
|
||||||
|
f.write_text("key: value")
|
||||||
|
f.chmod(0o644)
|
||||||
|
warnings = check_file_permissions(f)
|
||||||
|
assert len(warnings) >= 1
|
||||||
|
assert "0644" in warnings[0] or "permissions" in warnings[0].lower()
|
||||||
|
|
||||||
|
def test_silent_on_0600(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
f = Path(tmp) / "secure.yml"
|
||||||
|
f.write_text("key: value")
|
||||||
|
f.chmod(0o600)
|
||||||
|
warnings = check_file_permissions(f)
|
||||||
|
assert warnings == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandFilenameTemplate:
|
||||||
|
"""Tests for filename template expansion."""
|
||||||
|
|
||||||
|
def test_expands_all_placeholders(self):
|
||||||
|
result = expand_filename_template(
|
||||||
|
"report_{author}_{from}_{to}.{ext}",
|
||||||
|
author="Кокос А.А.",
|
||||||
|
from_date="2026-06-01",
|
||||||
|
to_date="2026-06-30",
|
||||||
|
ext="xlsx",
|
||||||
|
)
|
||||||
|
assert result == "report_Кокос_А.А._2026-06-01_2026-06-30.xlsx"
|
||||||
|
|
||||||
|
def test_date_placeholder_dd_mm_yyyy(self):
|
||||||
|
result = expand_filename_template(
|
||||||
|
"отчёт_{date}.{ext}",
|
||||||
|
author="Кокос А.А.",
|
||||||
|
from_date="2026-03-01",
|
||||||
|
to_date="2026-03-31",
|
||||||
|
ext="odt",
|
||||||
|
)
|
||||||
|
assert result == "отчёт_31_03_2026.odt"
|
||||||
|
|
||||||
|
def test_no_placeholders_returns_unchanged(self):
|
||||||
|
result = expand_filename_template(
|
||||||
|
"report.odt",
|
||||||
|
author="Кокос А.А.",
|
||||||
|
from_date="2026-01-01",
|
||||||
|
to_date="2026-01-31",
|
||||||
|
ext="odt",
|
||||||
|
)
|
||||||
|
assert result == "report.odt"
|
||||||
|
|
||||||
|
def test_unknown_placeholder_left_as_is(self):
|
||||||
|
result = expand_filename_template(
|
||||||
|
"{author}_{unknown}.{ext}",
|
||||||
|
author="Кокос А.А.",
|
||||||
|
from_date="2026-01-01",
|
||||||
|
to_date="2026-01-31",
|
||||||
|
ext="xlsx",
|
||||||
|
)
|
||||||
|
assert result == "Кокос_А.А._{unknown}.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveOutputPath:
|
||||||
|
"""Tests for resolve_output_path()."""
|
||||||
|
|
||||||
|
def test_none_returns_none(self):
|
||||||
|
from redmine_reporter.yaml_config import resolve_output_path
|
||||||
|
|
||||||
|
assert resolve_output_path(None) is None
|
||||||
|
|
||||||
|
def test_explicit_path_with_known_extension_is_unchanged(self):
|
||||||
|
from redmine_reporter.yaml_config import resolve_output_path
|
||||||
|
|
||||||
|
result = resolve_output_path("/tmp/report.xlsx")
|
||||||
|
assert result == "/tmp/report.xlsx"
|
||||||
|
|
||||||
|
def test_explicit_path_with_unknown_extension_is_unchanged(self):
|
||||||
|
from redmine_reporter.yaml_config import resolve_output_path
|
||||||
|
|
||||||
|
result = resolve_output_path("report.odt")
|
||||||
|
assert result == "report.odt"
|
||||||
|
|
||||||
|
def test_no_extension_appends_default_format(self):
|
||||||
|
from redmine_reporter.yaml_config import resolve_output_path
|
||||||
|
|
||||||
|
result = resolve_output_path("report", default_format="xlsx")
|
||||||
|
assert result == "report.xlsx"
|
||||||
|
|
||||||
|
def test_no_extension_appends_custom_default_format(self):
|
||||||
|
from redmine_reporter.yaml_config import resolve_output_path
|
||||||
|
|
||||||
|
result = resolve_output_path("file", default_format="csv")
|
||||||
|
assert result == "file.csv"
|
||||||
|
|
||||||
|
def test_bare_format_name_resolves_to_template_path(self):
|
||||||
|
from redmine_reporter.yaml_config import resolve_output_path
|
||||||
|
|
||||||
|
result = resolve_output_path(
|
||||||
|
"xlsx",
|
||||||
|
output_dir="/tmp/reports",
|
||||||
|
filename_template="{date}.{ext}",
|
||||||
|
author="Кокос А.А.",
|
||||||
|
from_date="2026-06-01",
|
||||||
|
to_date="2026-06-30",
|
||||||
|
default_format="xlsx",
|
||||||
|
)
|
||||||
|
assert result == "/tmp/reports/30_06_2026.xlsx"
|
||||||
|
|
||||||
|
def test_bare_format_name_odt_resolves_to_template_path(self):
|
||||||
|
from redmine_reporter.yaml_config import resolve_output_path
|
||||||
|
|
||||||
|
result = resolve_output_path(
|
||||||
|
"odt",
|
||||||
|
output_dir="~/reports",
|
||||||
|
filename_template="{author}_{from}_{to}.{ext}",
|
||||||
|
author="Кокос А.А.",
|
||||||
|
from_date="2026-06-01",
|
||||||
|
to_date="2026-06-30",
|
||||||
|
default_format="xlsx",
|
||||||
|
)
|
||||||
|
assert result.startswith("/") and result.endswith(
|
||||||
|
"/reports/Кокос_А.А._2026-06-01_2026-06-30.odt"
|
||||||
|
)
|
||||||
|
assert "~" not in result
|
||||||
|
|
||||||
|
def test_bare_format_name_with_empty_output_dir_uses_cwd(self):
|
||||||
|
from redmine_reporter.yaml_config import resolve_output_path
|
||||||
|
|
||||||
|
result = resolve_output_path(
|
||||||
|
"csv",
|
||||||
|
output_dir="",
|
||||||
|
filename_template="report.{ext}",
|
||||||
|
author="A",
|
||||||
|
from_date="2026-01-01",
|
||||||
|
to_date="2026-01-31",
|
||||||
|
)
|
||||||
|
assert not result.startswith("/")
|
||||||
|
assert "report.csv" in result
|
||||||
|
|
||||||
|
def test_unknown_bare_format_is_treated_as_path(self):
|
||||||
|
from redmine_reporter.yaml_config import resolve_output_path
|
||||||
|
|
||||||
|
result = resolve_output_path("unknown_format")
|
||||||
|
assert result == "unknown_format.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"redmine:\n"
|
||||||
|
" url: https://example.com\n"
|
||||||
|
" author: Test\n"
|
||||||
|
"output:\n"
|
||||||
|
" dir: /tmp\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
mode = config_path.stat().st_mode & 0o777
|
||||||
|
assert mode == 0o600
|
||||||
Reference in New Issue
Block a user