Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95afaf05a1 | ||
|
|
375ea13a1f | ||
|
|
f00c6705ba | ||
|
|
5d1cf4ab74 | ||
|
|
3ce4f408ed | ||
|
|
27d21ef639 | ||
|
|
db069dfb4b | ||
|
|
4a9a21624d | ||
|
|
35fa585bd0 | ||
|
|
3accb1212c | ||
|
|
3b9cfdcf7d | ||
|
|
c8df40fe5c | ||
|
|
1dc19f8c1a | ||
|
|
29e7615d20 | ||
|
|
8614062ecd | ||
|
|
09f6062e8c | ||
|
|
eef09538e6 | ||
|
|
da1bd72332 | ||
|
|
1c0ada2baf | ||
|
|
1683b0f893 | ||
|
|
1df1194f58 | ||
|
|
829f1b73fd | ||
|
|
debdede97a | ||
|
|
fa428e42fa | ||
|
|
fb1ca1d9b8 | ||
|
|
b624a8b8c2 | ||
|
|
a1febd6999 | ||
|
|
594db90227 | ||
|
|
598f2d35a1 | ||
|
|
46674ba926 | ||
|
|
c4ec23048a | ||
|
|
d135408f5e | ||
|
|
3a3a7bb39c | ||
|
|
7ca58f881e | ||
|
|
e587684ad7 | ||
|
|
5f51d40fef | ||
|
|
22f1733a5d | ||
|
|
17b0e99aa3 | ||
|
|
8992bb922e | ||
|
|
608afe08e3 | ||
|
|
e1862462af | ||
|
|
863ad50cc3 | ||
|
|
5e1c366a60 | ||
|
|
0968560090 | ||
|
|
25425901b1 | ||
|
|
b0e353c565 | ||
|
|
b926dd0d49 | ||
|
|
80faccb1f9 | ||
|
|
9b78d66769 | ||
|
|
485be063d2 | ||
|
|
47152f8f04 | ||
|
|
67350bfcd6 | ||
|
|
0fa4e271a7 | ||
|
|
b1a565bc9e | ||
|
|
c962a93f30 | ||
|
|
676f7ede30 | ||
|
|
5dd234e7b3 | ||
|
|
59af7ce464 | ||
|
|
f6861382e6 | ||
|
|
67b5d093d9 | ||
|
|
f80f3a8b52 | ||
|
|
222d31730e |
26
.gitea/workflows/checks.yaml
Normal file
26
.gitea/workflows/checks.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
- run: pip install -e .[dev]
|
||||||
|
- run: isort --check-only redmine_reporter tests
|
||||||
|
- run: black --check redmine_reporter tests
|
||||||
|
- run: ruff check redmine_reporter tests
|
||||||
|
- run: ruff format --check redmine_reporter tests
|
||||||
|
- run: mypy redmine_reporter
|
||||||
|
- run: pytest -v
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -85,10 +85,11 @@ secrets.json
|
|||||||
# Temporary files
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
docs/superpowers
|
||||||
|
|
||||||
# Just in case
|
# Just in case
|
||||||
.~*
|
.~*
|
||||||
report.odt
|
# Local report outputs
|
||||||
report.csv
|
report.*
|
||||||
report.md
|
rep.*
|
||||||
*.html
|
*.html
|
||||||
|
|||||||
255
README.md
255
README.md
@@ -1,29 +1,34 @@
|
|||||||
# redmine-reporter
|
# redmine-reporter
|
||||||
|
|
||||||
CLI-инструмент для генерации отчётов по задачам Redmine на основе записей о затраченном времени.
|
[](https://git.akokos.ru/artem.kokos/redmine-reporter/actions)
|
||||||
|
|
||||||
Проект предназначен для внутреннего использования с `https://red.eltex.loc/`.
|
- [Возможности](#возможности)
|
||||||
|
- [Установка](#установка)
|
||||||
|
- [Быстрый старт](#быстрый-старт)
|
||||||
|
- [Документация](#документация)
|
||||||
|
- [Форматы вывода](#форматы-вывода)
|
||||||
|
- [Разработка](#разработка)
|
||||||
|
- [Безопасность](#безопасность)
|
||||||
|
- [Лицензия](#лицензия)
|
||||||
|
|
||||||
Лицензия: MIT.
|
CLI-инструмент для генерации отчётов по задачам Redmine на основе записей о затраченном времени. Читает time entries текущего или указанного пользователя, группирует задачи по проекту и версии, выводит отчёт в консоль или экспортирует в файл. Предназначен для внутреннего использования с `https://red.eltex.loc/`.
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Получение time entries текущего пользователя из Redmine.
|
- Отчёт по time entries текущего или указанного пользователя (`--user-id`, `--user-login`, `--user-name`).
|
||||||
- Авторизация через Redmine API token.
|
- Группировка задач по проекту и версии, перевод статусов на русский язык.
|
||||||
- Резервная авторизация через логин и пароль для обратной совместимости.
|
- Вывод в консоль (таблица или компактный вид) и экспорт в ODT, CSV, Markdown, HTML, JSON, XLSX.
|
||||||
- Группировка задач по проекту и версии.
|
- Разбивка времени по типам активности (`--by-activity`), сводка (`--summary`), скрытие времени (`--no-time`).
|
||||||
- Перевод статусов задач на русский язык.
|
- Гибкий выбор периода: `--date`, env `FROM_DATE`, YAML `period.from`, по умолчанию — текущий месяц.
|
||||||
- Вывод в консоль в табличном или компактном виде.
|
- `--commit`: сохранение отчёта в файл и авто-сдвиг начала периода в конфиге для следующего запуска.
|
||||||
- Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
|
- `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо) с авто-сдвигом начала периода при успешной отправке.
|
||||||
- Сводка по затраченному времени (итоги и разбивка по проектам/версиям).
|
- `--my-tasks`: список назначенных вам открытых задач -- таблица с сортировкой по срочности (приоритет, срок версии) и сводкой; см. [руководство](docs/USER_GUIDE.md#список-открытых-задач).
|
||||||
- Автоматическое определение месяца ODT-отчёта по конечной дате периода.
|
- YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md).
|
||||||
- Настройка периода отчёта по умолчанию через `.env` (или автоматически — текущий месяц).
|
|
||||||
- Переопределение URL и API-ключа через CLI.
|
|
||||||
- Загрузка альтернативного `.env` через `--config`.
|
|
||||||
- Загрузка альтернативного `.env` через `--config`.
|
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
|
Требуется Python >= 3.10.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git
|
git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git
|
||||||
cd redmine-reporter
|
cd redmine-reporter
|
||||||
@@ -33,177 +38,73 @@ pip install --upgrade pip
|
|||||||
pip install .
|
pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Настройка
|
Для разработки:
|
||||||
|
|
||||||
Создайте файл `.env` в корне проекта. Файл не должен попадать в git.
|
|
||||||
|
|
||||||
Рекомендуемый вариант авторизации:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
REDMINE_URL=https://red.eltex.loc/
|
|
||||||
REDMINE_API_KEY=ваш_api_token
|
|
||||||
REDMINE_AUTHOR=Иванов Иван Иванович
|
|
||||||
|
|
||||||
DEFAULT_FROM_DATE=2026-01-01
|
|
||||||
DEFAULT_TO_DATE=2026-01-31
|
|
||||||
```
|
|
||||||
|
|
||||||
Если задан `REDMINE_API_KEY`, он используется в первую очередь. Значения из `.env` можно переопределить через CLI: `--url`, `--api-key`, `--author`, а также загрузить другой файл конфигурации через `--config`.
|
|
||||||
|
|
||||||
Резервный вариант авторизации:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
REDMINE_URL=https://red.eltex.loc/
|
|
||||||
REDMINE_USER=ваш.логин
|
|
||||||
REDMINE_PASSWORD=ваш_пароль
|
|
||||||
REDMINE_AUTHOR=Иванов Иван Иванович
|
|
||||||
|
|
||||||
DEFAULT_FROM_DATE=2026-01-01
|
|
||||||
DEFAULT_TO_DATE=2026-01-31
|
|
||||||
```
|
|
||||||
|
|
||||||
Переменные окружения:
|
|
||||||
|
|
||||||
| Переменная | Обязательность | Описание |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `REDMINE_URL` | Да | URL Redmine. |
|
|
||||||
| `REDMINE_API_KEY` | Да, если нет логина и пароля | Redmine API token. |
|
|
||||||
| `REDMINE_USER` | Да, если нет токена | Логин Redmine. |
|
|
||||||
| `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. |
|
|
||||||
| `REDMINE_AUTHOR` | Нет | Имя автора для ODT-отчёта. |
|
|
||||||
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию в формате `YYYY-MM-DD`. |
|
|
||||||
| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию в формате `YYYY-MM-DD`. |
|
|
||||||
| `REDMINE_VERIFY` | Нет | Настройка TLS-проверки для Redmine API. |
|
|
||||||
|
|
||||||
`REDMINE_VERIFY` поддерживает значения:
|
|
||||||
|
|
||||||
- пустое значение или отсутствие переменной: `/etc/ssl/certs/ca-certificates.crt`;
|
|
||||||
- `true`, `1`, `yes`, `on`: стандартная проверка сертификатов `requests`;
|
|
||||||
- `false`, `0`, `no`, `off`: отключить проверку сертификатов;
|
|
||||||
- любой другой текст: путь к CA bundle.
|
|
||||||
|
|
||||||
Отключать проверку сертификатов не рекомендуется.
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source .venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
Отчёт за период по умолчанию (текущий месяц или из `.env`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter
|
|
||||||
```
|
|
||||||
|
|
||||||
Отчёт за произвольный период:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --date 2026-02-01--2026-02-28
|
|
||||||
```
|
|
||||||
|
|
||||||
Период должен быть задан в формате `YYYY-MM-DD--YYYY-MM-DD`. Начальная дата не может быть позже конечной.
|
|
||||||
|
|
||||||
Переопределение URL и API-ключа из `.env`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --url https://red.example.com --api-key ваш_токен
|
|
||||||
```
|
|
||||||
|
|
||||||
Использование альтернативного конфигурационного файла:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --config /path/to/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Компактный вывод:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --compact
|
|
||||||
```
|
|
||||||
|
|
||||||
Подробный или отладочный вывод:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --verbose
|
|
||||||
redmine-reporter --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
Вывод версии:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --version
|
|
||||||
```
|
|
||||||
|
|
||||||
Экспорт:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --output report.odt
|
|
||||||
redmine-reporter --output report.csv
|
|
||||||
redmine-reporter --output report.md
|
|
||||||
redmine-reporter --output report.html
|
|
||||||
redmine-reporter --output report.json
|
|
||||||
redmine-reporter --output report.xlsx
|
|
||||||
```
|
|
||||||
|
|
||||||
JSON-отчёт:
|
|
||||||
|
|
||||||
- массив объектов с полями `project`, `version`, `issue_id`, `subject`, `status`, `time`;
|
|
||||||
- UTF-8, читаемый машиной.
|
|
||||||
|
|
||||||
Excel-отчёт (.xlsx):
|
|
||||||
|
|
||||||
- одна таблица с заголовками и строками данных;
|
|
||||||
- шапка выделена жирным;
|
|
||||||
- каждая строка содержит полные значения проекта и версии.
|
|
||||||
|
|
||||||
CSV-отчёт:
|
|
||||||
|
|
||||||
- файл сохраняется в UTF-8 с BOM (`utf-8-sig`) для корректного отображения кириллицы в Microsoft Excel;
|
|
||||||
- каждая строка содержит полные значения проекта и версии (в отличие от консольного и Markdown-вывода, где повторяющиеся значения скрыты для компактности).
|
|
||||||
|
|
||||||
HTML-отчёт:
|
|
||||||
|
|
||||||
- полноценный HTML-документ с `<meta charset="utf-8">`;
|
|
||||||
- корректно отображается в браузере и почтовых клиентах.
|
|
||||||
|
|
||||||
ODT-отчёт:
|
|
||||||
|
|
||||||
- месяц в заголовке определяется по `to_date`;
|
|
||||||
- имя автора берётся из `--author`, затем из `REDMINE_AUTHOR`;
|
|
||||||
- если автор не задан, поле автора остаётся пустым.
|
|
||||||
|
|
||||||
Вывод без затраченного времени:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --no-time
|
|
||||||
```
|
|
||||||
|
|
||||||
Сводка по времени (итоги и разбивка по проектам):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --summary
|
|
||||||
```
|
|
||||||
|
|
||||||
## Разработка
|
|
||||||
|
|
||||||
Установка зависимостей для разработки:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
```
|
```
|
||||||
|
|
||||||
Проверки:
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сгенерировать конфиг ~/.config/redmine-reporter/config.yml
|
||||||
|
redmine-reporter --init-config
|
||||||
|
|
||||||
|
# Заполнить redmine.url, redmine.api_key (или ${REDMINE_API_KEY}), redmine.author
|
||||||
|
vim ~/.config/redmine-reporter/config.yml
|
||||||
|
|
||||||
|
# Отчёт за текущий месяц в консоль
|
||||||
|
redmine-reporter
|
||||||
|
|
||||||
|
# Сохранить в файл и сдвинуть начало периода для следующего запуска
|
||||||
|
redmine-reporter --commit
|
||||||
|
|
||||||
|
# Список ваших открытых задач (сортировка по срочности)
|
||||||
|
redmine-reporter --my-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
- [docs/USER_GUIDE.md](docs/USER_GUIDE.md) — руководство пользователя: сценарии использования, справочник всех CLI-флагов, устранение неполадок.
|
||||||
|
- [docs/CONFIG.md](docs/CONFIG.md) — справочник конфигурации: YAML-структура, переменные окружения, приоритеты, безопасность.
|
||||||
|
|
||||||
|
## Форматы вывода
|
||||||
|
|
||||||
|
| Формат | Особенности |
|
||||||
|
| --- | --- |
|
||||||
|
| Консоль | Таблица или компактный вид (`--compact`). |
|
||||||
|
| ODT | Требуется `odfpy`; формирование по шаблону. |
|
||||||
|
| CSV | UTF-8 с BOM; полные значения `project`/`version` в каждой строке. |
|
||||||
|
| Markdown | Компактная таблица. |
|
||||||
|
| HTML | Полный HTML-документ; объединение ячеек групп через rowspan. |
|
||||||
|
| JSON | Объекты `project`, `version`, `issue_id`, `subject`, `status`, `time` + опционально `activities`. |
|
||||||
|
| XLSX | Объединение ячеек по проекту/версии, итоги, автоширина (максимум 80), автофильтр, freeze panes. |
|
||||||
|
|
||||||
|
Нюанс `--no-time`: физически удаляет колонку времени только CSV; в XLSX колонки остаются, но пустыми и без итогов; в остальных форматах — пустые значения.
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
Проверки перед коммитом:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest
|
pytest
|
||||||
|
isort --check-only redmine_reporter tests
|
||||||
|
black --check redmine_reporter tests
|
||||||
ruff check redmine_reporter tests
|
ruff check redmine_reporter tests
|
||||||
black redmine_reporter tests
|
ruff format --check redmine_reporter tests
|
||||||
isort redmine_reporter tests
|
mypy redmine_reporter
|
||||||
```
|
```
|
||||||
|
|
||||||
|
CI — Gitea Actions (`.gitea/workflows/checks.yaml`): все шесть проверок на матрице Python 3.10–3.13.
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- Не коммитьте `.env`, API token, пароль или логин.
|
- `REDMINE_URL` обязан использовать HTTPS: валидация отклоняет остальное, API-ключ передаётся в заголовках.
|
||||||
- Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач.
|
- `verify_ssl` / `REDMINE_VERIFY`: `true` (по умолчанию — проверка по системному хранилищу CA операционной системы через truststore, корпоративные CA из ОС работают), `false` (предупреждение о MITM-риске при старте) или путь к CA-bundle.
|
||||||
- Инструмент работает только в режиме чтения и не изменяет данные в Redmine.
|
- Конфиг создаётся с правами `0600`, директория — `0700`; при более широких правах выводится предупреждение.
|
||||||
|
- Секреты храните через `${VAR}` в YAML или в переменных окружения, не в открытом виде.
|
||||||
|
- Инструмент только читает данные из Redmine и ничего в нём не изменяет.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT, см. [LICENSE](LICENSE).
|
||||||
|
|||||||
546
docs/CONFIG.md
Normal file
546
docs/CONFIG.md
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
# Настройка redmine-reporter
|
||||||
|
|
||||||
|
## Оглавление
|
||||||
|
|
||||||
|
- [Источники конфигурации](#источники-конфигурации)
|
||||||
|
- [YAML-конфиг](#yaml-конфиг)
|
||||||
|
- [Структура](#структура)
|
||||||
|
- [`redmine.verify_ssl` — проверка TLS-сертификата](#redmineverify_ssl--проверка-tls-сертификата)
|
||||||
|
- [`period.from` — начало окна отчёта](#periodfrom--начало-окна-отчёта)
|
||||||
|
- [`--date` — формат диапазона](#--date--формат-диапазона)
|
||||||
|
- [Авто-сдвиг начала периода](#авто-сдвиг-начала-периода)
|
||||||
|
- [Миграция старых ключей периода](#миграция-старых-ключей-периода)
|
||||||
|
- [`email` — настройка отправки по почте](#email--настройка-отправки-по-почте)
|
||||||
|
- [`--send` — отправка отчёта по email](#--send--отправка-отчёта-по-email)
|
||||||
|
- [`output` — путь и имя файла по умолчанию](#output--путь-и-имя-файла-по-умолчанию)
|
||||||
|
- [Подстановка переменных окружения](#подстановка-переменных-окружения)
|
||||||
|
- [`report` — настройки содержимого отчёта](#report--настройки-содержимого-отчёта)
|
||||||
|
- [`report.status_translation` — перевод статусов](#reportstatus_translation--перевод-статусов)
|
||||||
|
- [Разрешение выходного пути](#разрешение-выходного-пути)
|
||||||
|
- [Миграция с `.env` на YAML](#миграция-с-env-на-yaml)
|
||||||
|
- [Быстрый старт](#быстрый-старт)
|
||||||
|
- [Что делает `--init-config`](#что-делает---init-config)
|
||||||
|
- [Флаги миграции](#флаги-миграции)
|
||||||
|
- [Проверка после миграции](#проверка-после-миграции)
|
||||||
|
- [Сосуществование `.env` и YAML](#сосуществование-env-и-yaml)
|
||||||
|
- [Откат](#откат)
|
||||||
|
- [`.env` (legacy)](#env-legacy)
|
||||||
|
- [Безопасность](#безопасность)
|
||||||
|
|
||||||
|
## Источники конфигурации
|
||||||
|
|
||||||
|
Приоритет, от высшего к низшему:
|
||||||
|
|
||||||
|
```
|
||||||
|
CLI-флаги > переменные окружения > .env > YAML > кодовые дефолты
|
||||||
|
```
|
||||||
|
|
||||||
|
Нюанс с `override` при автозагрузке `.env` и `--config` — см.
|
||||||
|
[Сосуществование `.env` и YAML](#сосуществование-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:
|
||||||
|
from: "2026-06-01"
|
||||||
|
|
||||||
|
output:
|
||||||
|
dir: ~/reports
|
||||||
|
filename: "{author}_{from}_{to}.{ext}"
|
||||||
|
default_format: xlsx
|
||||||
|
|
||||||
|
report:
|
||||||
|
no_time: false
|
||||||
|
|
||||||
|
email:
|
||||||
|
html: false
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `redmine.verify_ssl` — проверка TLS-сертификата
|
||||||
|
|
||||||
|
Управляет проверкой TLS-сертификата Redmine. Семантика едина с переменной
|
||||||
|
окружения `REDMINE_VERIFY`:
|
||||||
|
|
||||||
|
| Значение | Поведение |
|
||||||
|
|---|---|
|
||||||
|
| `true` (по умолчанию) | Проверка по системному хранилищу CA операционной системы (через truststore) — корпоративные CA, добавленные в ОС, работают без настройки |
|
||||||
|
| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM |
|
||||||
|
| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть |
|
||||||
|
|
||||||
|
До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь
|
||||||
|
`/etc/ssl/certs/ca-certificates.crt`, который существует только в
|
||||||
|
Debian/Ubuntu. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
|
||||||
|
одинаково — оба включают проверку по системному хранилищу CA на любой ОС
|
||||||
|
(через truststore), без привязки к конкретному пути.
|
||||||
|
|
||||||
|
### `period.from` — начало окна отчёта
|
||||||
|
|
||||||
|
Единственный ключ периода в YAML. Дата в формате `YYYY-MM-DD`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
period:
|
||||||
|
from: "2026-06-01"
|
||||||
|
```
|
||||||
|
|
||||||
|
Окно отчёта — `from..сегодня`: конец окна всегда сегодняшняя дата и отдельно
|
||||||
|
не настраивается. Переменная окружения `FROM_DATE` задаёт то же начало и имеет
|
||||||
|
приоритет над YAML:
|
||||||
|
|
||||||
|
```
|
||||||
|
--date > FROM_DATE (env) > period.from (YAML) > 1-е число текущего месяца
|
||||||
|
```
|
||||||
|
|
||||||
|
Разрешение начала окна:
|
||||||
|
|
||||||
|
| Условие | Начало окна |
|
||||||
|
|---|---|
|
||||||
|
| `from` не задан (`FROM_DATE` и `period.from` пусты) | 1-е число текущего месяца |
|
||||||
|
| `from` — корректная дата | `from` |
|
||||||
|
| `from` позже сегодняшней даты | сегодня (окно «сегодня--сегодня») |
|
||||||
|
| `from` некорректна (не `YYYY-MM-DD`) | warning в stderr + 1-е число текущего месяца |
|
||||||
|
|
||||||
|
Граничный день (день коммита) становится началом следующего окна и потому
|
||||||
|
перечитывается: уже отправленные записи этого дня повторятся в следующем
|
||||||
|
отчёте. Это осознанное следствие отсутствия дедупликации; зато поздние
|
||||||
|
записи, добавленные в Redmine в день коммита после отправки, не теряются.
|
||||||
|
Повторный запуск `--commit`/`--send` в тот же день даёт окно
|
||||||
|
«сегодня--сегодня».
|
||||||
|
|
||||||
|
`period.from` записывается автоматически при `--commit`/`--send`; вручную
|
||||||
|
редактировать не требуется.
|
||||||
|
|
||||||
|
### `--date` — формат диапазона
|
||||||
|
|
||||||
|
Флаг `--date` задаёт диапазон явно и переопределяет все остальные источники.
|
||||||
|
Каждая граница — дата `YYYY-MM-DD` или datetime `YYYY-MM-DDTHH:MM:SS`:
|
||||||
|
|
||||||
|
- `2026-06-01--2026-06-30`;
|
||||||
|
- `2026-06-30T09:00:00--2026-06-30T12:00:00`.
|
||||||
|
|
||||||
|
Время в границах отбрасывается — окно всегда состоит из полных дней, так как
|
||||||
|
гранулярность Redmine — день. Границы можно смешивать (дата + datetime).
|
||||||
|
Начало позже конца (`start > end`) — ошибка.
|
||||||
|
|
||||||
|
### Авто-сдвиг начала периода
|
||||||
|
|
||||||
|
`--commit` и `--send` генерируют отчёт, сохраняют его в файл (по `--output`
|
||||||
|
или по шаблону `output.dir`/`output.filename`) и сдвигают начало окна.
|
||||||
|
`--send` дополнительно отправляет отчёт по email (см.
|
||||||
|
[ниже](#--send--отправка-отчёта-по-email)).
|
||||||
|
|
||||||
|
После успешного `--commit` (файл сохранён) или реальной отправки `--send`
|
||||||
|
(подтверждение «y» + успешная отправка) в конфиг пишется
|
||||||
|
`period.from = <конец окна отчёта>`. Следующий запуск без `--date` начинает
|
||||||
|
окно с граничного дня последнего отчёта.
|
||||||
|
|
||||||
|
Сдвиг НЕ происходит:
|
||||||
|
|
||||||
|
- если за период нет time entries (отчёт пуст — выход до сохранения);
|
||||||
|
- при отказе от подтверждения отправки в `--send` (ответ не «y»/«yes»);
|
||||||
|
- при ошибке SMTP в `--send` (соединение, аутентификация, таймаут);
|
||||||
|
- при ошибке сохранения файла отчёта.
|
||||||
|
|
||||||
|
Исключение: при явном `--commit` (сочетание `--send --commit`) отказ от
|
||||||
|
подтверждения отправки сдвиг не отменяет: окно всё равно сдвигается по
|
||||||
|
`--commit`.
|
||||||
|
|
||||||
|
При сочетании `--send --commit` ошибка SMTP подавляет и сдвиг по `--commit`:
|
||||||
|
файл остаётся сохранён, но начало периода не двигается.
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Окно 1--30 июня → следующий запуск (без --date) начнётся с 30 июня
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30 --commit
|
||||||
|
|
||||||
|
# Произвольный диапазон 15--20 июня → следующее окно с 20 июня
|
||||||
|
redmine-reporter --date 2026-06-15--2026-06-20 --commit
|
||||||
|
|
||||||
|
# Отправить и сдвинуть окно (только при подтверждении «y» и успехе)
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
|
||||||
|
```
|
||||||
|
|
||||||
|
`--commit`/`--send` перезаписывают только `period.from`; остальные секции и
|
||||||
|
ключи YAML сохраняются. Файл перезаписывается через `yaml.dump` —
|
||||||
|
пользовательские комментарии и ручное форматирование теряются.
|
||||||
|
|
||||||
|
### Миграция старых ключей периода
|
||||||
|
|
||||||
|
Старые ключи периода больше не читаются и тихо игнорируются при загрузке —
|
||||||
|
их можно удалить из YAML:
|
||||||
|
|
||||||
|
- `period.last_used` (включая `from`/`to`);
|
||||||
|
- `period.precision`;
|
||||||
|
- `period.dynamic`;
|
||||||
|
- `period.default_from` / `period.default_to`.
|
||||||
|
|
||||||
|
Env `DEFAULT_FROM_DATE` / `DEFAULT_TO_DATE` заменены на `FROM_DATE`; конец
|
||||||
|
периода больше не настраивается (всегда сегодня). Дедупликация time entries
|
||||||
|
по `created_on`/`updated_on` удалена.
|
||||||
|
|
||||||
|
### `email` — настройка отправки по почте
|
||||||
|
|
||||||
|
Секция `email` используется флагом `--send`. Если секция не настроена или `smtp.host`
|
||||||
|
пуст, `--send` завершится с ошибкой «Email не настроен».
|
||||||
|
|
||||||
|
**Все поля:**
|
||||||
|
|
||||||
|
| Поле | Тип | По умолчанию | Описание |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `smtp.host` | строка | `""` | Адрес SMTP-сервера |
|
||||||
|
| `smtp.port` | число | `587` | Порт SMTP |
|
||||||
|
| `smtp.user` | строка | `""` | Логин для аутентификации |
|
||||||
|
| `smtp.password` | строка | `""` | Пароль (рекомендуется `${SMTP_PASSWORD}`) |
|
||||||
|
| `smtp.tls` | bool | `true` | Использовать STARTTLS |
|
||||||
|
| `from` | строка | `""` | Адрес отправителя |
|
||||||
|
| `to` | список | `[]` | Основные получатели |
|
||||||
|
| `cc` | список | `[]` | Копия |
|
||||||
|
| `bcc` | список | `[]` | Скрытая копия (не отображается в заголовках письма) |
|
||||||
|
| `subject` | строка | `"Отчёт {author} за {period}"` | Тема письма |
|
||||||
|
| `body_text` | строка | `"Во вложении отчёт."` | Текст письма (plain text) |
|
||||||
|
| `attach` | bool | `true` | Прикреплять файл отчёта. Если `false` — только текст |
|
||||||
|
| `html` | bool | `false` | Добавить HTML-версию тела письма (`multipart/alternative`) |
|
||||||
|
|
||||||
|
**Подстановки в `subject` и `body_text`:**
|
||||||
|
|
||||||
|
| Плейсхолдер | Описание | Пример |
|
||||||
|
|---|---|---|
|
||||||
|
| `{author}` | Имя автора из конфига или `--author` | `Кокос А.А.` |
|
||||||
|
| `{period}` | Строка диапазона дат | `2026-06-01--2026-06-30` |
|
||||||
|
|
||||||
|
**MIME-тип вложения** определяется по расширению файла:
|
||||||
|
|
||||||
|
| Расширение | MIME-тип |
|
||||||
|
|---|---|
|
||||||
|
| `.xlsx` | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` |
|
||||||
|
| `.odt` | `application/vnd.oasis.opendocument.text` |
|
||||||
|
| `.csv` | `text/csv` |
|
||||||
|
| `.html` | `text/html` |
|
||||||
|
| `.json` | `application/json` |
|
||||||
|
| `.md` | `text/markdown` |
|
||||||
|
|
||||||
|
Неизвестное расширение → `application/octet-stream`.
|
||||||
|
|
||||||
|
**Пример конфигурации:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
email:
|
||||||
|
html: false
|
||||||
|
smtp:
|
||||||
|
host: smtp.example.com
|
||||||
|
port: 587
|
||||||
|
user: bot@example.com
|
||||||
|
password: ${SMTP_PASSWORD}
|
||||||
|
tls: true
|
||||||
|
from: bot@example.com
|
||||||
|
to:
|
||||||
|
- boss@example.com
|
||||||
|
- team-lead@example.com
|
||||||
|
cc:
|
||||||
|
- manager@example.com
|
||||||
|
bcc: []
|
||||||
|
subject: "Отчёт {author} за {period}"
|
||||||
|
body_text: "Во вложении отчёт за период {period}."
|
||||||
|
attach: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--send` — отправка отчёта по email
|
||||||
|
|
||||||
|
Флаг `--send` сохраняет отчёт в файл, отправляет его через SMTP и сдвигает
|
||||||
|
начало периода при подтверждении и успешной отправке (см.
|
||||||
|
[Авто-сдвиг](#авто-сдвиг-начала-периода)). Требует настроенную
|
||||||
|
секцию `email` в YAML-конфиге.
|
||||||
|
|
||||||
|
**Что делает:**
|
||||||
|
|
||||||
|
1. Генерирует отчёт как обычно.
|
||||||
|
2. Сохраняет отчёт в файл:
|
||||||
|
- Если указан `--output` — по явному пути.
|
||||||
|
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
|
||||||
|
3. Запрашивает подтверждение: «Отправить? [y/N]». Ответ «y»/«yes» продолжает,
|
||||||
|
иначе отправка отменяется (без сдвига периода).
|
||||||
|
4. Формирует MIME-письмо:
|
||||||
|
- Тема, plain-text тело и вложение (если `attach: true`).
|
||||||
|
- При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта.
|
||||||
|
5. Отправляет через SMTP (таймаут 30 секунд). STARTTLS включается только при
|
||||||
|
`email.smtp.tls: true`; аутентификация — только если задан `email.smtp.user`.
|
||||||
|
6. При успешной отправке сдвигает начало периода.
|
||||||
|
|
||||||
|
**Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся
|
||||||
|
на диске, данные не теряются.
|
||||||
|
|
||||||
|
**Ошибки SMTP:**
|
||||||
|
|
||||||
|
- Нет соединения → `"Не удалось подключиться к SMTP-серверу host:port"`
|
||||||
|
- Неверный логин/пароль → `"Ошибка аутентификации SMTP. Проверьте логин и пароль."`
|
||||||
|
- Таймаут → `"Таймаут соединения с SMTP-сервером."`
|
||||||
|
- Другая ошибка → `"Ошибка отправки письма: <детали>"`
|
||||||
|
|
||||||
|
Все ошибки выводятся в stderr, код возврата 1.
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Отправить отчёт за июнь (сохранится по шаблону output.filename)
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30 --send
|
||||||
|
|
||||||
|
# С явным путём
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30 --output ~/report.xlsx --send
|
||||||
|
|
||||||
|
# Отправить и сдвинуть начало периода
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
|
||||||
|
```
|
||||||
|
|
||||||
|
**Совместимость с другими флагами:**
|
||||||
|
|
||||||
|
| Комбинация | Поведение |
|
||||||
|
|---|---|
|
||||||
|
| `--send` | Сохранить по шаблону → отправить |
|
||||||
|
| `--send --output X` | Сохранить в X → отправить |
|
||||||
|
| `--send --commit` | Сохранить → подтвердить → отправить → сдвинуть начало периода |
|
||||||
|
| `--send` с `email.html: true` | Письмо с plain-text + HTML-таблицей |
|
||||||
|
| `--send` без `email` в конфиге | Ошибка, exit 1 |
|
||||||
|
| `--send` при ошибке SMTP | Файл сохранён, ошибка в stderr, exit 1 |
|
||||||
|
|
||||||
|
### `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` — основная защита.
|
||||||
|
|
||||||
|
### `report` — настройки содержимого отчёта
|
||||||
|
|
||||||
|
Секция управляет тем, что попадает в сгенерированный отчёт.
|
||||||
|
|
||||||
|
| Поле | Тип | По умолчанию | Описание |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта |
|
||||||
|
| `status_translation` | map[str,str] | `{}` | Переопределение/дополнение перевода статусов |
|
||||||
|
|
||||||
|
`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`).
|
||||||
|
При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`.
|
||||||
|
CLI-флаг `--no-time` всегда имеет приоритет над YAML.
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
report:
|
||||||
|
no_time: true
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Автоматический режим: время не выводится
|
||||||
|
redmine-reporter --commit
|
||||||
|
|
||||||
|
# Ручной режим: report.no_time игнорируется, время выводится
|
||||||
|
redmine-reporter --output report.odt
|
||||||
|
|
||||||
|
# Ручной режим с явным флагом: время не выводится
|
||||||
|
redmine-reporter --output report.odt --no-time
|
||||||
|
```
|
||||||
|
|
||||||
|
### `report.status_translation` — перевод статусов
|
||||||
|
|
||||||
|
По умолчанию статусы Redmine переводятся встроенным словарём
|
||||||
|
(`New` → `В работе`, `Closed` → `Закрыто` и т.д.). Секция
|
||||||
|
`report.status_translation` переопределяет отдельные переводы и/или
|
||||||
|
добавляет новые статусы; не указанные здесь статусы переводятся
|
||||||
|
встроенным словарём, а совсем неизвестные выводятся как есть.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
report:
|
||||||
|
status_translation:
|
||||||
|
"New": "Новая"
|
||||||
|
"Wait Release": "Ожидает релиза"
|
||||||
|
"Custom Status": "Кастомный статус"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Разрешение выходного пути
|
||||||
|
|
||||||
|
Функция `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` (неизвестное расширение) → ошибка «Неизвестный формат файла», exit 1.
|
||||||
|
|
||||||
|
## Миграция с `.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`, `report`, `email`).
|
||||||
|
- В конец файла дописывает закомментированный пример `report.status_translation`.
|
||||||
|
- Секреты (`REDMINE_API_KEY`, `SMTP_PASSWORD`) записывает как `${VAR}`, если
|
||||||
|
переменная существует, иначе — пустая строка.
|
||||||
|
- Создаёт файл с правами `0600`, директорию — с `0700`.
|
||||||
|
|
||||||
|
### Флаги миграции
|
||||||
|
|
||||||
|
| Флаг | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `--init-config` | Создать YAML и выйти |
|
||||||
|
| `--init-config --force` | Перезаписать существующий YAML |
|
||||||
|
| `--config-path PATH` | Путь к YAML-конфигу: загрузка при запуске, запись при `--init-config` и `--commit` (по умолчанию `~/.config/redmine-reporter/config.yml`) |
|
||||||
|
|
||||||
|
Если задана `FROM_DATE`, сгенерированный YAML будет содержать `period.from` с
|
||||||
|
этим значением.
|
||||||
|
|
||||||
|
### Проверка после миграции
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Автозагрузка `.env` (без `--config`) не перебивает реальные переменные
|
||||||
|
окружения (`override=False`). Исключение — `--config FILE`: указанный `.env`
|
||||||
|
загружается с `override=True` и перебивает переменные окружения (но не CLI-флаги).
|
||||||
|
|
||||||
|
Это safe — если с YAML что-то пойдёт не так, просто положи `.env` обратно.
|
||||||
|
|
||||||
|
### Откат
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm ~/.config/redmine-reporter/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.env` (legacy)
|
||||||
|
|
||||||
|
Для обратной совместимости `.env` продолжает работать как раньше, кроме ключей
|
||||||
|
периода: `DEFAULT_FROM_DATE` заменён на `FROM_DATE`, `DEFAULT_TO_DATE` удалён
|
||||||
|
(см. [Миграция старых ключей периода](#миграция-старых-ключей-периода)).
|
||||||
|
|
||||||
|
```ini
|
||||||
|
REDMINE_URL=https://red.eltex.loc
|
||||||
|
REDMINE_API_KEY=your-api-key
|
||||||
|
REDMINE_AUTHOR=Кокос А.А.
|
||||||
|
FROM_DATE=2026-06-01
|
||||||
|
```
|
||||||
|
|
||||||
|
Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц
|
||||||
|
как период, стандартная проверка TLS, пустой автор).
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- YAML-конфиг: права `0600`, директория `0700`.
|
||||||
|
- Права шире `0600` → warning в stderr при каждом запуске.
|
||||||
|
- Секреты рекомендуется хранить через `${VAR}`, а не plaintext.
|
||||||
|
- `.env` **не рекомендуется** для постоянных настроек — оставьте его только
|
||||||
|
для CI/CD или временных переопределений.
|
||||||
381
docs/USER_GUIDE.md
Normal file
381
docs/USER_GUIDE.md
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
# Руководство пользователя redmine-reporter
|
||||||
|
|
||||||
|
## Оглавление
|
||||||
|
|
||||||
|
- [Установка](#установка)
|
||||||
|
- [Первоначальная настройка](#первоначальная-настройка)
|
||||||
|
- [Выбор периода](#выбор-периода)
|
||||||
|
- [Отчёт за другого пользователя](#отчёт-за-другого-пользователя)
|
||||||
|
- [Список открытых задач](#список-открытых-задач)
|
||||||
|
- [Экспорт в файл](#экспорт-в-файл)
|
||||||
|
- [Содержимое отчёта](#содержимое-отчёта)
|
||||||
|
- [Отправка по email](#отправка-по-email)
|
||||||
|
- [Скользящее окно периода](#скользящее-окно-периода)
|
||||||
|
- [Справочник флагов](#справочник-флагов)
|
||||||
|
- [Устранение неполадок](#устранение-неполадок)
|
||||||
|
- [См. также](#см-также)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Требуется Python >= 3.10.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git
|
||||||
|
cd redmine-reporter
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед каждым запуском активируйте окружение: `source .venv/bin/activate`.
|
||||||
|
|
||||||
|
## Первоначальная настройка
|
||||||
|
|
||||||
|
1. Убедитесь, что в текущей директории есть `.env` с `REDMINE_URL` и `REDMINE_API_KEY` (или задайте эти переменные в окружении).
|
||||||
|
2. Сгенерируйте YAML-конфиг:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --init-config
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат: файл `~/.config/redmine-reporter/config.yml` (права `0600`, директория `0700`) со всеми секциями и закомментированным примером `report.status_translation`. Секреты, заданные в окружении, записываются как `${VAR}`.
|
||||||
|
|
||||||
|
3. Если файл уже существует — ошибка; для перезаписи добавьте `--force`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --init-config --force
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Нестандартное расположение конфига:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --init-config --config-path /etc/redmine-reporter/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
`--config-path` задаёт путь и для загрузки YAML, и для записи при `--init-config` и `--commit`.
|
||||||
|
|
||||||
|
5. Откройте файл и заполните минимум: `redmine.url`, `redmine.api_key`, `redmine.author`.
|
||||||
|
|
||||||
|
`--init-config` несовместим с флагами отчёта (`--date`, `--output`, `--compact`, `--summary`, `--user-id`/`--user-login`/`--user-name`, `--no-time`, `--by-activity`, `--send`) — указывайте его отдельно.
|
||||||
|
|
||||||
|
Полный референс всех секций и переменных: [CONFIG.md](CONFIG.md).
|
||||||
|
|
||||||
|
## Выбор периода
|
||||||
|
|
||||||
|
Окно отчёта — диапазон дат `from..to`. Конец (`to`) всегда сегодняшняя дата,
|
||||||
|
если диапазон не задан явно через `--date`.
|
||||||
|
|
||||||
|
Приоритет начала окна (от высшего к низшему):
|
||||||
|
|
||||||
|
1. `--date` — явный диапазон (полностью переопределяет окно).
|
||||||
|
2. `FROM_DATE` (env) — начало; конец — сегодня.
|
||||||
|
3. `period.from` (YAML) — начало; конец — сегодня.
|
||||||
|
4. Ничего не задано — текущий месяц: с 1-го числа по сегодня.
|
||||||
|
|
||||||
|
Форматы `--date`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Даты
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30
|
||||||
|
|
||||||
|
# Дата-время (границы флорятся до полных дней)
|
||||||
|
redmine-reporter --date 2026-06-01T09:00:00--2026-06-01T18:00:00
|
||||||
|
```
|
||||||
|
|
||||||
|
Границы с временем округляются до дат — отчёт строится за полные дни диапазона.
|
||||||
|
Границы можно смешивать (дата + дата-время). Начало позже конца
|
||||||
|
(`start > end`) — ошибка.
|
||||||
|
|
||||||
|
Краевые случаи:
|
||||||
|
|
||||||
|
- `from` позже сегодняшней даты — окно «сегодня--сегодня».
|
||||||
|
- `from` некорректна (не `YYYY-MM-DD`) — warning + текущий месяц.
|
||||||
|
|
||||||
|
## Отчёт за другого пользователя
|
||||||
|
|
||||||
|
Без флагов отчёт строится за текущего пользователя.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --user-id 42
|
||||||
|
redmine-reporter --user-login ivanov
|
||||||
|
redmine-reporter --user-name "Иванов Иван Иванович"
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- Флаги `--user-id`, `--user-login`, `--user-name` взаимоисключающие: укажите только один, иначе ошибка.
|
||||||
|
- Все три принимают одно значение. Число или строка из цифр → поиск по ID. Иначе — точное регистрозависимое совпадение логина.
|
||||||
|
- Если по логину совпадений нет — поиск по имени. Одно совпадение → пользователь найден.
|
||||||
|
- Несколько совпадений по имени → ошибка «Multiple users match...» со списком логинов (при дублях логина — ID).
|
||||||
|
- Ноль совпадений → ошибка «User '...' not found...».
|
||||||
|
|
||||||
|
## Список открытых задач
|
||||||
|
|
||||||
|
`--my-tasks` -- отдельный режим: вместо отчёта по затраченному времени выводит
|
||||||
|
открытые задачи, назначенные на вас, по всем проектам. Отчётное окно периода
|
||||||
|
к режиму не применяется.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --my-tasks # задачи владельца API-ключа
|
||||||
|
redmine-reporter --my-tasks --user-id 42 # чужой список по ID
|
||||||
|
redmine-reporter --my-tasks --user-login ivanov # по логину
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- `--user-id` / `--user-login` / `--user-name` выбирают, чей список показать; по умолчанию -- владелец API-ключа. Разрешение пользователей то же, что для отчёта (см. [Отчёт за другого пользователя](#отчёт-за-другого-пользователя)).
|
||||||
|
- `--url`, `--api-key`, `--config`, `--config-path`, `--verbose`, `--debug` работают как обычно.
|
||||||
|
- Совмещение с отчётными флагами (`--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`) -- ошибка «--my-tasks cannot be used with report-generating flags.», exit 1.
|
||||||
|
- Задачи в версии `Wishlist` исключаются из режима целиком: не попадают ни в таблицу, ни в сводку.
|
||||||
|
|
||||||
|
Сортировка -- от срочного к менее срочному:
|
||||||
|
|
||||||
|
1. Приоритет `P1..P5`: меньший номер срочнее; неизвестное имя приоритета получает низший ранг.
|
||||||
|
2. Внутри приоритетной группы -- срок версии (due date) по возрастанию.
|
||||||
|
3. Задачи без версии или без срока -- в конец своей приоритетной группы.
|
||||||
|
4. При прочих равных -- по возрастанию ID.
|
||||||
|
|
||||||
|
Таблица печатается в stdout, сводка -- в stderr перед таблицей. При
|
||||||
|
перенаправлении (`redmine-reporter --my-tasks > tasks.txt`) в файл попадает
|
||||||
|
только таблица.
|
||||||
|
|
||||||
|
Колонки таблицы: ID | Тема | Статус | Приор. | Версия | Срок. Пример
|
||||||
|
(синтетические данные, ID в терминале кликабелен):
|
||||||
|
|
||||||
|
```
|
||||||
|
ID Тема Статус Приор. Версия Срок
|
||||||
|
---- -------------------------------- -------------- -------- -------- --------------
|
||||||
|
101 Критический дефект сборки образа В работе P1 v2.4.0 2026-10-15 (!)
|
||||||
|
102 Обновить документацию API Новая P2 v2.5.0 2026-12-01
|
||||||
|
103 Ревью pull request Обратная связь P3 - -
|
||||||
|
```
|
||||||
|
|
||||||
|
- ID -- гиперссылка (OSC 8) на страницу задачи `<url>/issues/<id>`: в терминалах с поддержкой кликабельна, без поддержки остаётся обычным номером.
|
||||||
|
- Срок -- дата релиза целевой версии; просроченный (раньше сегодняшней даты) помечается `(!)`.
|
||||||
|
- Пустые «Версия» и «Срок» -- прочерк `-`.
|
||||||
|
- Статусы переводятся на русский тем же словарём, что и в отчётах: встроенный словарь + `report.status_translation` (см. [Перевод статусов](#перевод-статусов)).
|
||||||
|
|
||||||
|
Сводка (пример с синтетическими данными):
|
||||||
|
|
||||||
|
```
|
||||||
|
Открытых: 3 | просроченных: 1 | без версии: 1 | без срока: 0 | потрачено: 12.5h | оценка: 8h (по 2 из 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
- «просроченных» -- задачи со сроком версии раньше сегодняшней даты.
|
||||||
|
- «без версии» -- задачи без целевой версии; «без срока» -- задачи с версией, но без известной даты релиза.
|
||||||
|
- «потрачено» -- сумма полей `spent_hours` задач списка.
|
||||||
|
- «оценка» -- сумма полей `estimated_hours`; «(по N из M)» -- у скольких задач из списка есть оценка. Если оценок нет ни у одной задачи, блок «оценка» не выводится.
|
||||||
|
|
||||||
|
Сроки версий дозагружаются отдельным запросом на каждую уникальную версию
|
||||||
|
списка. Недоступная версия -- предупреждение в stderr и пустой срок у её
|
||||||
|
задач, вывод не прерывается.
|
||||||
|
|
||||||
|
Пустой список (в том числе когда все открытые задачи попали в исключаемую
|
||||||
|
версию `Wishlist`): «ℹ️ Открытых задач нет.» (stderr), exit 0.
|
||||||
|
|
||||||
|
## Экспорт в файл
|
||||||
|
|
||||||
|
Флаг `--output` определяется по четырём правилам:
|
||||||
|
|
||||||
|
| Аргумент | Результат |
|
||||||
|
| --- | --- |
|
||||||
|
| `--output xlsx` (bare-формат: `xlsx`, `odt`, `csv`, `md`, `html`, `json`, регистронезависимо) | Путь = `output.dir` + шаблон `output.filename` из YAML, расширение = указанный формат |
|
||||||
|
| `--output /tmp/report` (путь без расширения) | Дописывается `.` + `output.default_format` → `/tmp/report.xlsx` |
|
||||||
|
| `--output /tmp/report.csv` (путь с любым расширением) | Используется как есть |
|
||||||
|
| `--output /tmp/report.xyz` (неизвестное расширение) | Ошибка «Неизвестный формат файла: '.xyz'. Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx», exit 1 |
|
||||||
|
|
||||||
|
Формат `.odt` требует установленного пакета `odfpy` (устанавливается по умолчанию) — иначе отдельная ошибка.
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --output xlsx # по шаблону в output.dir
|
||||||
|
redmine-reporter --output 2026-06-report # → 2026-06-report.xlsx (если default_format: xlsx)
|
||||||
|
redmine-reporter --output reports/june.csv # как есть
|
||||||
|
```
|
||||||
|
|
||||||
|
Плейсхолдеры `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` |
|
||||||
|
|
||||||
|
Неизвестные плейсхолдеры остаются в имени как есть.
|
||||||
|
|
||||||
|
Нюансы с путями:
|
||||||
|
|
||||||
|
- `~` в явном `--output` раскрывает шелл, а не программа: `redmine-reporter --output ~/report.xlsx` работает, а в кавычках (`"~/report.xlsx"`) — нет.
|
||||||
|
- `expanduser` применяется только к `output.dir` из YAML.
|
||||||
|
|
||||||
|
## Содержимое отчёта
|
||||||
|
|
||||||
|
### `--no-time`
|
||||||
|
|
||||||
|
Убирает затраченное время из отчёта. Физически удаляет колонку времени только CSV. XLSX, HTML, ODT, Markdown, JSON и консольный вывод оставляют пустые колонки/поля; в XLSX дополнительно пропадают строки итогов по версиям/проектам/всего.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --output report.xlsx --no-time
|
||||||
|
```
|
||||||
|
|
||||||
|
YAML-настройка `report.no_time` действует только в автоматических режимах (`--commit`, `--send`). При ручном `--output` работает только CLI-флаг.
|
||||||
|
|
||||||
|
### `--by-activity`
|
||||||
|
|
||||||
|
Разбивка затраченного времени по типам активности. В JSON-отчёте добавляется поле `activities`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --by-activity
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--summary`
|
||||||
|
|
||||||
|
Печатает сводку в stderr (после отчёта):
|
||||||
|
|
||||||
|
- `total` — суммарное время;
|
||||||
|
- `project:*` — время по каждому проекту;
|
||||||
|
- `activity:*` — время по каждой активности (только при одновременном `--by-activity`).
|
||||||
|
|
||||||
|
Версии в сводку не входят.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --by-activity --summary
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--compact`
|
||||||
|
|
||||||
|
Компактный текстовый вывод в консоль вместо таблицы:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --compact
|
||||||
|
```
|
||||||
|
|
||||||
|
### Перевод статусов
|
||||||
|
|
||||||
|
Статусы задач переводятся встроенным словарём; секция `report.status_translation` переопределяет или дополняет переводы. Подробно: [CONFIG.md](CONFIG.md).
|
||||||
|
|
||||||
|
## Отправка по email
|
||||||
|
|
||||||
|
Требуется секция `email` в YAML-конфиге (см. [CONFIG.md](CONFIG.md)).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30 --send
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30 --output report.xlsx --send
|
||||||
|
redmine-reporter --send --commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Факты:
|
||||||
|
|
||||||
|
- Файл отчёта сохраняется на диск ДО отправки: при ошибке SMTP файл остаётся на месте.
|
||||||
|
- Перед отправкой запрашивается подтверждение «Отправить? [y/N]»; ответ «y»/«yes» продолжает, иначе отправка отменяется (без сдвига периода, если не задан явный `--commit`).
|
||||||
|
- При успешной отправке начало периода сдвигается (см. [Скользящее окно периода](#скользящее-окно-периода)); при отмене (если не задан явный `--commit`) или ошибке SMTP — без сдвига.
|
||||||
|
- Секция `email` отсутствует или `smtp.host` пуст → ошибка «Email не настроен. Добавьте секцию 'email' в конфиг...», exit 1.
|
||||||
|
- `email.html: true` → письмо `multipart/alternative`: plain-text + HTML-таблица отчёта в теле.
|
||||||
|
- Файл прикрепляется при `attach: true`; MIME-тип определяется по расширению, неизвестное → `application/octet-stream`.
|
||||||
|
- Получатели `bcc` указываются только в envelope (не видны в заголовках письма).
|
||||||
|
- STARTTLS применяется только при `smtp.tls: true`; аутентификация на SMTP-сервере — только если задан `smtp.user`.
|
||||||
|
- Таймаут соединения с SMTP — 30 секунд.
|
||||||
|
|
||||||
|
Точные тексты ошибок:
|
||||||
|
|
||||||
|
- «Ошибка аутентификации SMTP. Проверьте логин и пароль.»
|
||||||
|
- «Таймаут соединения с SMTP-сервером.»
|
||||||
|
- «Ошибка отправки письма: ...»
|
||||||
|
- «Не удалось подключиться к SMTP-серверу host:port»
|
||||||
|
|
||||||
|
## Скользящее окно периода
|
||||||
|
|
||||||
|
После успешного `--commit` (файл сохранён) или реальной отправки `--send`
|
||||||
|
(подтверждение «y» + успех) окно отчёта сдвигается вперёд: в конфиг пишется
|
||||||
|
`period.from = <конец окна>`. Следующий запуск без `--date`
|
||||||
|
начинается с граничного дня последнего отчёта.
|
||||||
|
|
||||||
|
Типичный цикл:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Отчёт за июнь 2026: сохранить, отправить, сдвинуть начало на 30 июня
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
|
||||||
|
|
||||||
|
# 2. Следующий запуск без --date берёт окно с 30 июня по сегодня
|
||||||
|
redmine-reporter --send --commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Граничный день: начало следующего окна совпадает с днём коммита, поэтому этот
|
||||||
|
день перечитывается. Записи дня коммита, уже попавшие в прошлый отчёт,
|
||||||
|
повторятся (дедупликации по `created_on`/`updated_on` нет — это осознанное
|
||||||
|
следствие), зато поздние записи, добавленные в Redmine в этот день после
|
||||||
|
отправки, не теряются.
|
||||||
|
|
||||||
|
Повторный запуск `--commit`/`--send` в тот же день даёт окно
|
||||||
|
«сегодня--сегодня», которое повторно включает уже отправленные записи этого
|
||||||
|
дня.
|
||||||
|
|
||||||
|
Важно: `--commit`/`--send` перезаписывают только `period.from`; остальные
|
||||||
|
ключи YAML сохраняются, но пользовательские комментарии теряются
|
||||||
|
(перезапись через `yaml.dump`).
|
||||||
|
|
||||||
|
## Справочник флагов
|
||||||
|
|
||||||
|
| Флаг | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `--date` | Явный диапазон: границы — дата `YYYY-MM-DD` или datetime `YYYY-MM-DDTHH:MM:SS`. Время отбрасывается (полные дни), границы можно смешивать; `start > end` — ошибка. |
|
||||||
|
| `--compact` | Компактный текстовый вывод в консоль вместо таблицы. |
|
||||||
|
| `--output` | Путь к файлу (`.odt`/`.csv`/`.md`/`.html`/`.json`/`.xlsx`) или bare-формат — тогда путь берётся из YAML-шаблона; без флага — вывод в консоль (stdout). |
|
||||||
|
| `--author` | Переопределить имя автора отчёта. |
|
||||||
|
| `--no-time` | Не включать затраченное время в отчёт. |
|
||||||
|
| `--url` | Переопределить URL Redmine. |
|
||||||
|
| `--api-key` | Переопределить API-ключ Redmine. |
|
||||||
|
| `--config` | Путь к альтернативному `.env`-файлу. |
|
||||||
|
| `--verbose` | Подробный вывод; показывает traceback при ошибках. |
|
||||||
|
| `--debug` | Отладочный вывод; показывает traceback при ошибках. |
|
||||||
|
| `--version` | Показать версию и выйти. |
|
||||||
|
| `--summary` | Сводка по времени в stderr: `total`, `project:*`, `activity:*` (версии не печатаются). |
|
||||||
|
| `--user-id` | Отчёт за пользователя по ID. |
|
||||||
|
| `--user-login` | Отчёт за пользователя по логину (точное регистрозависимое совпадение). |
|
||||||
|
| `--user-name` | Отчёт за пользователя по имени. |
|
||||||
|
| `--by-activity` | Разбивка времени по типам активности (в JSON — поле `activities`). |
|
||||||
|
| `--init-config` | Сгенерировать YAML-конфиг из текущего `.env`/окружения и выйти. |
|
||||||
|
| `--force` | Перезаписать существующий YAML-конфиг (только с `--init-config`). |
|
||||||
|
| `--config-path` | Путь к YAML-конфигу: загрузка, запись `--init-config` и `--commit`. По умолчанию `~/.config/redmine-reporter/config.yml`. |
|
||||||
|
| `--commit` | Сохранить отчёт в файл и сдвинуть начало периода (`period.from` = конец окна) в YAML. |
|
||||||
|
| `--send` | Отправить отчёт по email после сохранения файла; при подтверждении и успехе сдвинуть начало периода. |
|
||||||
|
| `--my-tasks` | Вместо отчёта вывести открытые задачи, назначенные на вас (или на пользователя из `--user-id`/`--user-login`/`--user-name`): таблица с сортировкой по срочности + сводка в stderr. Несовместим с `--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`. Подробнее: [Список открытых задач](#список-открытых-задач). |
|
||||||
|
|
||||||
|
## Устранение неполадок
|
||||||
|
|
||||||
|
| Ошибка | Причина | Решение |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| «REDMINE_URL must use HTTPS...» (exit 1) | URL Redmine не по HTTPS | Укажите `https://` в `redmine.url` / `REDMINE_URL` / `--url`. |
|
||||||
|
| «Authentication failed: invalid API key, login or password. Check REDMINE_API_KEY / REDMINE_USER / REDMINE_PASSWORD.» | Неверный или отсутствующий API-ключ, логин или пароль | Проверьте `redmine.api_key` / `--api-key` (или `REDMINE_USER` / `REDMINE_PASSWORD`). |
|
||||||
|
| «User '...' not found...» | Пользователь не найден ни по ID, ни по логину, ни по имени | Проверьте написание; логин сравнивается точно с учётом регистра. |
|
||||||
|
| «Multiple users match...» | По имени найдено несколько пользователей (или несколько дублей логина) | Используйте `--user-login` с логином из списка или уточните числовой ID для `--user-id`. |
|
||||||
|
| Указано несколько `--user-*` | Флаги взаимоисключающие | Оставьте только один из `--user-id` / `--user-login` / `--user-name`. |
|
||||||
|
| «--my-tasks cannot be used with report-generating flags.» (exit 1) | `--my-tasks` совмещён с отчётными флагами (`--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`) | Запускайте режим отдельно: список задач и отчёт -- разные команды (см. [Список открытых задач](#список-открытых-задач)). |
|
||||||
|
| «Неизвестный формат файла: '.xyz'. Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx» (exit 1) | `--output` с неизвестным расширением | Используйте одно из поддерживаемых расширений. |
|
||||||
|
| Ошибка про `odfpy` при `.odt` | Пакет `odfpy` не установлен | `pip install odfpy` или выберите другой формат. |
|
||||||
|
| «Email не настроен. Добавьте секцию 'email' в конфиг...» (exit 1) | `--send` без секции `email` в YAML или с пустым `smtp.host` | Добавьте секцию `email` (см. [CONFIG.md](CONFIG.md)). |
|
||||||
|
| «Ошибка аутентификации SMTP. Проверьте логин и пароль.» | Неверные `smtp.user`/`smtp.password` | Проверьте логин и пароль в конфиге. |
|
||||||
|
| «Таймаут соединения с SMTP-сервером.» | Сервер не ответил за 30 секунд | Проверьте сеть, `smtp.host` и `smtp.port`. |
|
||||||
|
| «Не удалось подключиться к SMTP-серверу host:port» | Нет соединения с SMTP | Проверьте `smtp.host`, `smtp.port` и доступность сервера. |
|
||||||
|
| «Ошибка отправки письма: ...» | Прочая ошибка SMTP | Смотрите детали после двоеточия; файл отчёта уже сохранён на диске. |
|
||||||
|
| Предупреждение о правах конфига при старте | Права `config.yml` шире `0600` | `chmod 600 ~/.config/redmine-reporter/config.yml`. |
|
||||||
|
| Предупреждение о MITM при старте | `verify_ssl: false` / `REDMINE_VERIFY=false` — проверка TLS отключена | Включите проверку или используйте только в доверенной сети. |
|
||||||
|
| «⚠️ Could not load version ...: Due date will be empty for its issues.» в режиме `--my-tasks` | Версия из списка задач недоступна (нет прав, удалена, сбой сети) | Некритично: срок этой версии останется пустым; проверьте права доступа к проекту версии. |
|
||||||
|
| Не виден traceback при ошибке | Traceback показывается только с `--verbose`/`--debug` | Повторите запуск с `--debug`. |
|
||||||
|
|
||||||
|
API-ключ в текстах ошибок маскируется (`***`).
|
||||||
|
|
||||||
|
## См. также
|
||||||
|
|
||||||
|
- [README.md](../README.md) — обзор, быстрый старт, форматы вывода.
|
||||||
|
- [CONFIG.md](CONFIG.md) — полный референс конфигурации (YAML, env, приоритеты, безопасность).
|
||||||
@@ -4,35 +4,39 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "redmine-reporter"
|
name = "redmine-reporter"
|
||||||
version = "1.6.0"
|
version = "2.1.0"
|
||||||
description = "Redmine time-entry based issue reporter for internal use"
|
description = "Redmine time-entry based issue reporter for internal use"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Operating System :: POSIX :: Linux",
|
"Operating System :: POSIX :: Linux",
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"python-redmine>=2.4.0",
|
"python-redmine>=2.4.0",
|
||||||
"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",
|
"openpyxl>=3.1.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
"requests>=2.31",
|
||||||
|
"truststore>=0.10",
|
||||||
|
"urllib3>=1.26",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
"black>=23.0",
|
|
||||||
"isort>=5.12",
|
|
||||||
"mypy>=1.0",
|
"mypy>=1.0",
|
||||||
"ruff>=0.1.0",
|
"ruff>=0.1.0",
|
||||||
|
"black>=24.0",
|
||||||
|
"isort>=5.0",
|
||||||
|
"setuptools>=61.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -45,17 +49,19 @@ include = ["redmine_reporter*"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
"redmine_reporter" = ["templates/template.odt"]
|
"redmine_reporter" = ["templates/template.odt"]
|
||||||
|
|
||||||
[tool.black]
|
[tool.pytest.ini_options]
|
||||||
line-length = 100
|
testpaths = ["tests"]
|
||||||
target-version = ['py39']
|
pythonpath = ["."]
|
||||||
|
|
||||||
[tool.isort]
|
|
||||||
profile = "black"
|
|
||||||
multi_line_output = 3
|
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*"]
|
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["redmine_reporter"]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.6.0"
|
__version__ = "2.1.0"
|
||||||
|
|||||||
@@ -4,45 +4,323 @@ import os
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from dotenv import find_dotenv, load_dotenv
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .client import fetch_issues_with_spent_time
|
from .client import RedmineAPIError, fetch_issues_with_spent_time, fetch_open_issues
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .formatters.factory import get_console_formatter, get_formatter_by_extension
|
from .formatters.factory import get_console_formatter, get_formatter_by_extension
|
||||||
|
from .formatters.task_table import TaskTableFormatter
|
||||||
|
from .mailer import send_report
|
||||||
from .report_builder import build_grouped_report, calculate_summary
|
from .report_builder import build_grouped_report, calculate_summary
|
||||||
|
from .task_list import (
|
||||||
|
build_task_rows,
|
||||||
|
calculate_task_summary,
|
||||||
|
filter_excluded_versions,
|
||||||
|
format_summary_line,
|
||||||
|
)
|
||||||
|
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
|
||||||
|
|
||||||
|
# Маскируем API-ключ Redmine в тексте ошибок (#55):
|
||||||
|
# - query-параметр key=<значение> в URL (?key=... / &key=...);
|
||||||
|
# - заголовок X-Redmine-API-Key: <значение>.
|
||||||
|
_SANITIZE_PATTERNS = [
|
||||||
|
(re.compile(r"([?&]key=)[^\s&\"')]+", re.IGNORECASE), r"\1***"),
|
||||||
|
(
|
||||||
|
re.compile(
|
||||||
|
r"(X-Redmine-API-Key[\"']?\s*[:=]\s*[\"']?)[^\s\"',}]+", re.IGNORECASE
|
||||||
|
),
|
||||||
|
r"\1***",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_error_text(text: str) -> str:
|
||||||
|
"""Маскирует API-ключ Redmine в тексте ошибки перед выводом в stderr (#55)."""
|
||||||
|
for pattern, replacement in _SANITIZE_PATTERNS:
|
||||||
|
text = pattern.sub(replacement, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_boundary(value: str) -> datetime:
|
||||||
|
"""Разбирает одну границу диапазона как дату или datetime без timezone."""
|
||||||
|
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||||
|
fmt = "%Y-%m-%d"
|
||||||
|
elif re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", value):
|
||||||
|
fmt = "%Y-%m-%dT%H:%M:%S"
|
||||||
|
else:
|
||||||
|
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value, fmt)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError("Date range contains invalid calendar date") from e
|
||||||
|
|
||||||
|
|
||||||
def parse_date_range(date_arg: str) -> tuple[str, str]:
|
def parse_date_range(date_arg: str) -> tuple[str, str]:
|
||||||
|
"""Возвращает границы диапазона в виде дат YYYY-MM-DD.
|
||||||
|
|
||||||
|
Каждая граница может быть датой или datetime; время отбрасывается
|
||||||
|
(границы окна -- целые дни), поэтому смешение форматов допустимо.
|
||||||
|
"""
|
||||||
if "--" not in date_arg:
|
if "--" not in date_arg:
|
||||||
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
||||||
parts = date_arg.split("--", 1)
|
parts = date_arg.split("--", 1)
|
||||||
|
|
||||||
from_date, to_date = parts[0].strip(), parts[1].strip()
|
from_date, to_date = parts[0].strip(), parts[1].strip()
|
||||||
date_pattern = r"\d{4}-\d{2}-\d{2}"
|
start = _parse_boundary(from_date)
|
||||||
if not re.fullmatch(date_pattern, from_date) or not re.fullmatch(date_pattern, to_date):
|
end = _parse_boundary(to_date)
|
||||||
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
|
||||||
|
|
||||||
try:
|
if start.date() > end.date():
|
||||||
start = datetime.strptime(from_date, "%Y-%m-%d").date()
|
|
||||||
end = datetime.strptime(to_date, "%Y-%m-%d").date()
|
|
||||||
except ValueError as e:
|
|
||||||
raise ValueError("Date range contains invalid calendar date") from e
|
|
||||||
|
|
||||||
if start > end:
|
|
||||||
raise ValueError("Date range start must be less than or equal to end")
|
raise ValueError("Date range start must be less than or equal to end")
|
||||||
|
|
||||||
return start.isoformat(), end.isoformat()
|
return start.date().isoformat(), end.date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[List[str]] = None) -> int:
|
def _run_init_config(config_path: str, force: bool) -> int:
|
||||||
|
"""Создаёт YAML-конфиг из текущих настроек окружения.
|
||||||
|
|
||||||
|
Подгружает .env из текущей директории (override=False: реальные
|
||||||
|
переменные окружения не перебиваются), чтобы --init-config видел
|
||||||
|
значения, заданные только в .env (#64). Поиск идёт от cwd
|
||||||
|
(usecwd=True): find_dotenv() без него стартует от директории
|
||||||
|
cli.py и .env пользователя не находит.
|
||||||
|
"""
|
||||||
|
load_dotenv(find_dotenv(usecwd=True), override=False)
|
||||||
|
|
||||||
|
path = Path(config_path)
|
||||||
|
|
||||||
|
if path.exists() and not force:
|
||||||
|
print(
|
||||||
|
f"⚠️ {path} already exists.\n 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": {
|
||||||
|
"from": os.getenv("FROM_DATE", "").strip(),
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"dir": "",
|
||||||
|
"filename": "{author}_{from}_{to}.{ext}",
|
||||||
|
"default_format": "xlsx",
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"no_time": False,
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"html": False,
|
||||||
|
"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
|
||||||
|
)
|
||||||
|
fh.write(
|
||||||
|
"\n"
|
||||||
|
"# report.status_translation: переопределение/дополнение перевода\n"
|
||||||
|
"# статусов Redmine. Без секции используется встроенный словарь.\n"
|
||||||
|
"# Пример:\n"
|
||||||
|
"# report:\n"
|
||||||
|
"# status_translation:\n"
|
||||||
|
'# "New": "Новая"\n'
|
||||||
|
'# "Wait Release": "Ожидает релиза"\n'
|
||||||
|
)
|
||||||
|
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 _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool:
|
||||||
|
"""Возвращает финальное значение no_time.
|
||||||
|
|
||||||
|
Приоритет:
|
||||||
|
1. CLI-флаг --no-time (всегда побеждает).
|
||||||
|
2. YAML report.no_time (только в автоматических режимах --commit/--send).
|
||||||
|
3. Иначе False.
|
||||||
|
"""
|
||||||
|
if cli_flag:
|
||||||
|
return True
|
||||||
|
if is_auto_mode:
|
||||||
|
return Config.get_report_no_time()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _save_and_maybe_send(
|
||||||
|
rows,
|
||||||
|
output_arg: str,
|
||||||
|
author: str,
|
||||||
|
from_date: str,
|
||||||
|
to_date: str,
|
||||||
|
no_time: bool,
|
||||||
|
do_send: bool,
|
||||||
|
) -> tuple[int, bool]:
|
||||||
|
"""Сохраняет отчёт в файл и опционально отправляет по email.
|
||||||
|
|
||||||
|
Returns (exit_code, sent): sent отражает факт реальной отправки письма,
|
||||||
|
чтобы main мог отличить отказ от подтверждения от успешной отправки.
|
||||||
|
"""
|
||||||
|
output_ext = os.path.splitext(output_arg)[1].lower()
|
||||||
|
|
||||||
|
if not output_ext:
|
||||||
|
print(
|
||||||
|
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1, False
|
||||||
|
|
||||||
|
formatter = get_formatter_by_extension(
|
||||||
|
output_ext,
|
||||||
|
author=author,
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
no_time=no_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not formatter:
|
||||||
|
if output_ext == ".odt":
|
||||||
|
print(
|
||||||
|
"❌ odfpy is not installed. Install with: pip install odfpy",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"❌ Неизвестный формат файла: {output_ext!r}. "
|
||||||
|
"Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1, False
|
||||||
|
|
||||||
|
try:
|
||||||
|
formatter.save(rows, output_arg)
|
||||||
|
print(f"✅ Report saved to {output_arg}")
|
||||||
|
except Exception as e: # noqa: BLE001 -- форматёры (odfpy/openpyxl/csv) бросают произвольные типы, CLI обязан вернуть код 1 на любой сбой
|
||||||
|
fmt = output_ext.lstrip(".").upper()
|
||||||
|
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
|
||||||
|
return 1, False
|
||||||
|
|
||||||
|
if do_send:
|
||||||
|
email_config = Config.get_email_config()
|
||||||
|
if email_config is None:
|
||||||
|
print(
|
||||||
|
"❌ Email не настроен. Добавьте секцию 'email' в конфиг "
|
||||||
|
"(~/.config/redmine-reporter/config.yml).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1, False
|
||||||
|
|
||||||
|
print("\n📧 Готово к отправке:")
|
||||||
|
print(f" From: {email_config.from_}")
|
||||||
|
print(f" To: {', '.join(email_config.to)}")
|
||||||
|
if email_config.cc:
|
||||||
|
print(f" Cc: {', '.join(email_config.cc)}")
|
||||||
|
if email_config.bcc:
|
||||||
|
print(f" Bcc: {', '.join(email_config.bcc)}")
|
||||||
|
print(f" File: {output_arg}")
|
||||||
|
response = input("Отправить? [y/N]: ").strip().lower()
|
||||||
|
if response not in ("y", "yes"):
|
||||||
|
print("⏭️ Отправка отменена.")
|
||||||
|
return 0, False
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_report(
|
||||||
|
email_config,
|
||||||
|
output_arg,
|
||||||
|
author,
|
||||||
|
f"{from_date}--{to_date}",
|
||||||
|
rows=rows,
|
||||||
|
)
|
||||||
|
print(f"📧 Report sent to {', '.join(email_config.to)}")
|
||||||
|
except RedmineAPIError as e:
|
||||||
|
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
|
||||||
|
return 1, False
|
||||||
|
return 0, True
|
||||||
|
|
||||||
|
return 0, False
|
||||||
|
|
||||||
|
|
||||||
|
def _run_my_tasks(args: argparse.Namespace) -> int:
|
||||||
|
"""Режим --my-tasks: таблица открытых задач пользователя в stdout (#71).
|
||||||
|
|
||||||
|
Саммари печатается в stderr, а не в stdout: при перенаправлении вывода
|
||||||
|
в файл остаётся только таблица без служебных строк.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
items = fetch_open_issues(
|
||||||
|
user_id=args.user_id or args.user_login or args.user_name
|
||||||
|
)
|
||||||
|
except RedmineAPIError as e:
|
||||||
|
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
|
||||||
|
if (args.verbose or args.debug) and e.original is not None:
|
||||||
|
# Точка входа CLI: root-логгер осознанно настроен через basicConfig
|
||||||
|
# в main(), module-логгер здесь дал бы второй набор хендлеров.
|
||||||
|
logging.exception("Original Redmine API error") # noqa: LOG015
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
|
||||||
|
if args.verbose or args.debug:
|
||||||
|
logging.exception("Unexpected error") # noqa: LOG015
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Бакет-версии (Wishlist) исключаются до проверки на пустоту: если все
|
||||||
|
# открытые задачи в них, пользователь видит "Открытых задач нет".
|
||||||
|
items = filter_excluded_versions(items)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
print("ℹ️ Открытых задач нет.", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
rows = build_task_rows(items, Config.get_status_translation())
|
||||||
|
summary = calculate_task_summary(items)
|
||||||
|
print(format_summary_line(summary), file=sys.stderr)
|
||||||
|
print(file=sys.stderr)
|
||||||
|
try:
|
||||||
|
table = TaskTableFormatter(base_url=Config.get_redmine_url()).format(rows)
|
||||||
|
except Exception as e: # noqa: BLE001 -- сбой рендера таблицы не должен ронять CLI трейсбеком: печатаем ошибку и возвращаем код 1
|
||||||
|
print(f"❌ Formatting error: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(table)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="redmine-reporter",
|
prog="redmine-reporter",
|
||||||
description="Generate Redmine issue report based on your time entries.",
|
description="Generate Redmine issue report based on your time entries.",
|
||||||
)
|
)
|
||||||
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: current month or from .env)",
|
help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: current month or from .env)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -61,7 +339,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
"--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("--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(
|
||||||
|
"--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("--config", help="Path to .env config file")
|
||||||
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
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("--debug", action="store_true", help="Enable debug output")
|
||||||
@@ -76,14 +356,116 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Print summary (total hours by project/version) to stderr",
|
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 the report and advance period start to its end date in YAML config",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--send",
|
||||||
|
action="store_true",
|
||||||
|
help="Send generated report via email after saving (requires email section in config); on success advance period start to its end date",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--my-tasks",
|
||||||
|
action="store_true",
|
||||||
|
help="List open issues assigned to you, sorted by urgency, and exit",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
# --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,
|
||||||
|
args.send,
|
||||||
|
]
|
||||||
|
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)
|
||||||
|
|
||||||
|
# --my-tasks: отдельный режим, отчётное окно и файлы к нему неприменимы
|
||||||
|
if args.my_tasks:
|
||||||
|
report_flags = [
|
||||||
|
args.date is not None,
|
||||||
|
args.output,
|
||||||
|
args.compact,
|
||||||
|
args.summary,
|
||||||
|
args.no_time,
|
||||||
|
args.by_activity,
|
||||||
|
args.commit,
|
||||||
|
args.send,
|
||||||
|
]
|
||||||
|
if any(report_flags):
|
||||||
|
print(
|
||||||
|
"❌ --my-tasks cannot be used with report-generating flags.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Валидация взаимоисключающих флагов пользователя
|
||||||
|
user_args = [args.user_id, args.user_login, args.user_name]
|
||||||
|
if sum(bool(a) for a in user_args) > 1:
|
||||||
|
print(
|
||||||
|
"❌ Specify only one of --user-id, --user-login, or --user-name.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
# CLI-переопределения имеют приоритет над .env/env.
|
# CLI-переопределения имеют приоритет над .env/env.
|
||||||
if args.config:
|
if args.config:
|
||||||
Config.load_config(args.config)
|
Config.load_config(args.config)
|
||||||
Config.set_redmine_url(args.url)
|
Config.set_redmine_url(args.url)
|
||||||
Config.set_redmine_api_key(args.api_key)
|
Config.set_redmine_api_key(args.api_key)
|
||||||
|
|
||||||
|
# Автозагрузка YAML-конфига
|
||||||
|
yaml_path = args.config_path
|
||||||
|
Config.load_yaml(yaml_path)
|
||||||
|
|
||||||
# Настройка уровня логирования
|
# Настройка уровня логирования
|
||||||
if args.debug:
|
if args.debug:
|
||||||
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
|
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
|
||||||
@@ -98,77 +480,145 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
print(f"❌ Configuration error: {e}", file=sys.stderr)
|
print(f"❌ Configuration error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# Ветка --my-tasks: даты отчётного окна режиму не нужны.
|
||||||
|
if args.my_tasks:
|
||||||
|
return _run_my_tasks(args)
|
||||||
|
|
||||||
|
# Если --date не указан, используем дефолтный диапазон
|
||||||
|
date_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(
|
||||||
except Exception as e:
|
from_date,
|
||||||
print(f"❌ Redmine API error: {e}", file=sys.stderr)
|
to_date,
|
||||||
|
user_id=args.user_id or args.user_login or args.user_name,
|
||||||
|
by_activity=args.by_activity,
|
||||||
|
)
|
||||||
|
except RedmineAPIError as e:
|
||||||
|
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
|
||||||
|
if (args.verbose or args.debug) and e.original is not None:
|
||||||
|
# Точка входа CLI: root-логгер осознанно настроен через basicConfig
|
||||||
|
# в main(), module-логгер здесь дал бы второй набор хендлеров.
|
||||||
|
logging.exception("Original Redmine API error") # noqa: LOG015
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
|
||||||
|
if args.verbose or args.debug:
|
||||||
|
logging.exception("Unexpected error") # noqa: LOG015
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
if issue_hours is None:
|
|
||||||
print("ℹ️ No time entries found in the given period.", file=sys.stderr)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if not issue_hours:
|
if not issue_hours:
|
||||||
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}]", file=sys.stderr)
|
print(f"✅ Total issues: {len(issue_hours)} [{date_arg}]", file=sys.stderr)
|
||||||
|
|
||||||
rows = build_grouped_report(issue_hours, fill_time=not args.no_time)
|
is_auto_mode = args.commit or args.send
|
||||||
|
no_time = _resolve_no_time(args.no_time, is_auto_mode)
|
||||||
|
|
||||||
|
rows = build_grouped_report(
|
||||||
|
issue_hours,
|
||||||
|
fill_time=not no_time,
|
||||||
|
by_activity=args.by_activity,
|
||||||
|
status_translation=Config.get_status_translation(),
|
||||||
|
)
|
||||||
|
|
||||||
if args.summary:
|
if args.summary:
|
||||||
summary = calculate_summary(rows)
|
summary = calculate_summary(rows, by_activity=args.by_activity)
|
||||||
print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr)
|
print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr)
|
||||||
for key, value in summary.items():
|
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:"):
|
if key.startswith("project:"):
|
||||||
project = key.split(":", 1)[1]
|
project = key.split(":", 1)[1]
|
||||||
print(f" {project}: {value}h", file=sys.stderr)
|
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:
|
save_ok = False
|
||||||
output_ext = os.path.splitext(args.output)[1].lower()
|
report_sent = False
|
||||||
|
|
||||||
if not output_ext:
|
if args.output or args.commit:
|
||||||
|
if args.output:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# --commit без --output: используем default_format
|
||||||
|
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(
|
print(
|
||||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
"❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
formatter = get_formatter_by_extension(
|
ret, report_sent = _save_and_maybe_send(
|
||||||
output_ext,
|
rows,
|
||||||
|
output_arg,
|
||||||
|
author=Config.get_author(args.author),
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
no_time=no_time,
|
||||||
|
do_send=args.send,
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
return ret
|
||||||
|
save_ok = True
|
||||||
|
|
||||||
|
elif args.send:
|
||||||
|
# --send без --output и --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),
|
author=Config.get_author(args.author),
|
||||||
from_date=from_date,
|
from_date=from_date,
|
||||||
to_date=to_date,
|
to_date=to_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not formatter:
|
if output_arg is None:
|
||||||
if output_ext == ".odt":
|
print(
|
||||||
print(
|
"❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr
|
||||||
"❌ odfpy is not installed. Install with: pip install odfpy",
|
)
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
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:
|
ret, report_sent = _save_and_maybe_send(
|
||||||
formatter.save(rows, args.output)
|
rows,
|
||||||
print(f"✅ Report saved to {args.output}")
|
output_arg,
|
||||||
except Exception as e:
|
author=Config.get_author(args.author),
|
||||||
fmt = output_ext.lstrip(".").upper()
|
from_date=from_date,
|
||||||
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
|
to_date=to_date,
|
||||||
return 1
|
no_time=no_time,
|
||||||
|
do_send=True,
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
return ret
|
||||||
|
save_ok = True
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if args.compact:
|
if args.compact:
|
||||||
@@ -183,10 +633,32 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
try:
|
try:
|
||||||
output = formatter.format(rows)
|
output = formatter.format(rows)
|
||||||
print(output)
|
print(output)
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001 -- консольный форматтер не должен ронять CLI: печатаем ошибку и возвращаем код 1
|
||||||
print(f"❌ Formatting error: {e}", file=sys.stderr)
|
print(f"❌ Formatting error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# Окно двигается ровно один раз за запуск: либо по явному --commit
|
||||||
|
# (операция завершилась кодом 0), либо по фактически отправленному письму --send.
|
||||||
|
should_advance = (args.commit and save_ok) or (args.send and report_sent)
|
||||||
|
if should_advance:
|
||||||
|
# Начало следующего окна совпадает с концом текущего: граничный день
|
||||||
|
# перечитывается. Дубли записей дня коммита осознанны, зато поздние
|
||||||
|
# записи, добавленные в тот же день после отправки, не теряются.
|
||||||
|
next_from = to_date
|
||||||
|
|
||||||
|
try:
|
||||||
|
save_period_to_config(args.config_path, from_str=next_from)
|
||||||
|
except Exception as e: # noqa: BLE001 -- сбой записи конфига не должен ронять CLI трейсбеком: сообщаем и возвращаем код 1
|
||||||
|
print(
|
||||||
|
f"❌ Не удалось сохранить период в конфиг: {e}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
print(
|
||||||
|
f"📌 Period committed [{next_from}] → {args.config_path}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
from typing import Any, Dict, List, Optional, Tuple
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import truststore
|
||||||
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 urllib3.util.retry import Retry
|
||||||
|
|
||||||
@@ -14,7 +18,16 @@ REQUEST_TIMEOUT = 30
|
|||||||
ISSUE_ID_CHUNK_SIZE = 100
|
ISSUE_ID_CHUNK_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
def _get_redmine_auth_kwargs() -> Dict[str, Any]:
|
class RedmineAPIError(Exception):
|
||||||
|
"""Пользовательское исключение с понятным сообщением об ошибке Redmine API."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, original: Exception | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.original = original
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redmine_auth_kwargs() -> dict[str, Any]:
|
||||||
"""Return Redmine auth kwargs. API key has priority over legacy password auth."""
|
"""Return Redmine auth kwargs. API key has priority over legacy password auth."""
|
||||||
api_key = Config.get_redmine_api_key()
|
api_key = Config.get_redmine_api_key()
|
||||||
if api_key:
|
if api_key:
|
||||||
@@ -37,12 +50,21 @@ def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
|
|||||||
|
|
||||||
|
|
||||||
def _create_redmine() -> Redmine:
|
def _create_redmine() -> Redmine:
|
||||||
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24)."""
|
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24).
|
||||||
|
|
||||||
|
При verify=True подключает системное хранилище CA ОС через
|
||||||
|
truststore.inject_into_ssl() (#62).
|
||||||
|
"""
|
||||||
|
verify = Config.get_redmine_verify()
|
||||||
|
if verify is True:
|
||||||
|
# verify_ssl: true — проверка по системному хранилищу CA ОС (truststore),
|
||||||
|
# а не по certifi: корпоративные CA из ОС продолжают работать (#62).
|
||||||
|
truststore.inject_into_ssl()
|
||||||
redmine = Redmine(
|
redmine = Redmine(
|
||||||
Config.get_redmine_url(),
|
Config.get_redmine_url(),
|
||||||
**_get_redmine_auth_kwargs(),
|
**_get_redmine_auth_kwargs(),
|
||||||
requests={
|
requests={
|
||||||
"verify": Config.get_redmine_verify(),
|
"verify": verify,
|
||||||
"timeout": REQUEST_TIMEOUT,
|
"timeout": REQUEST_TIMEOUT,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -56,55 +78,322 @@ def _create_redmine() -> Redmine:
|
|||||||
return redmine
|
return redmine
|
||||||
|
|
||||||
|
|
||||||
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
|
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 as exc: # noqa: BLE001 -- python-redmine бросает разные типы; справочник не критичен, деградируем к данным из entries
|
||||||
|
# #61: не глотаем сбой молча — предупреждаем, что разбивка по
|
||||||
|
# активностям будет построена по сырым данным из самих entries.
|
||||||
|
print(
|
||||||
|
f"⚠️ Could not load time entry activities: {exc}. "
|
||||||
|
"Activity names will be taken from time entries.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
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 _fetch_issues_chunked(redmine: Redmine, issue_ids: list[int]) -> list[Issue]:
|
||||||
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
|
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
|
||||||
all_issues: List[Issue] = []
|
all_issues: list[Issue] = []
|
||||||
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
|
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
|
||||||
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
|
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
|
||||||
issue_list_str = ",".join(str(x) for x in chunk)
|
issue_list_str = ",".join(str(x) for x in chunk)
|
||||||
issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc")
|
issues = redmine.issue.filter(
|
||||||
|
issue_id=issue_list_str, status_id="*", sort="project:asc"
|
||||||
|
)
|
||||||
all_issues.extend(issues)
|
all_issues.extend(issues)
|
||||||
return all_issues
|
return all_issues
|
||||||
|
|
||||||
|
|
||||||
def fetch_issues_with_spent_time(
|
def _resolve_user_id(redmine: Redmine, user_arg: int | str) -> int:
|
||||||
from_date: str, to_date: str
|
"""Преобразует строковый идентификатор пользователя в числовой ID.
|
||||||
) -> Optional[List[Tuple[Issue, float]]]:
|
|
||||||
"""
|
|
||||||
Fetch unique issues linked to time entries of the current user in given date range,
|
|
||||||
along with total spent hours per issue.
|
|
||||||
Returns list of (issue, total_hours) tuples.
|
|
||||||
"""
|
|
||||||
|
|
||||||
redmine = _create_redmine()
|
Если аргумент — число, возвращает его как есть.
|
||||||
|
Если строка, пытается найти пользователя по логину или имени.
|
||||||
|
"""
|
||||||
|
if isinstance(user_arg, int):
|
||||||
|
return user_arg
|
||||||
|
|
||||||
current_user = redmine.user.get("current")
|
text = str(user_arg).strip()
|
||||||
time_entries = redmine.time_entry.filter(
|
if not text:
|
||||||
user_id=current_user.id, from_date=from_date, to_date=to_date
|
raise RedmineAPIError("User identifier cannot be empty.")
|
||||||
|
|
||||||
|
# Сначала пробуем интерпретировать как числовой ID
|
||||||
|
if text.isdigit():
|
||||||
|
return int(text)
|
||||||
|
|
||||||
|
# Затем ищем по логину
|
||||||
|
try:
|
||||||
|
users = redmine.user.filter(login=text)
|
||||||
|
# Фильтр Redmine по логину неточный (substring-поиск), поэтому
|
||||||
|
# выбираем только точные регистрозависимые совпадения логина (#60).
|
||||||
|
exact_matches = [u for u in users if getattr(u, "login", None) == text]
|
||||||
|
if len(exact_matches) == 1:
|
||||||
|
return int(exact_matches[0].id)
|
||||||
|
if len(exact_matches) > 1:
|
||||||
|
matches = ", ".join(str(u.id) for u in exact_matches[: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 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
|
|
||||||
spent_time: Dict[int, float] = {}
|
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: int | str | None = None,
|
||||||
|
by_activity: bool = False,
|
||||||
|
) -> list[tuple[Issue, float, dict[str, float] | None]] | None:
|
||||||
|
"""
|
||||||
|
Fetch unique issues linked to time entries of the given user in date range,
|
||||||
|
along with total spent hours per issue.
|
||||||
|
If user_id is None, uses current user.
|
||||||
|
If by_activity is True, returns per-activity breakdown as third tuple element.
|
||||||
|
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
|
||||||
|
|
||||||
|
# Агрегируем часы по issue.id (и активности, если требуется)
|
||||||
|
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)
|
# Загружаем полные объекты задач чанками (#21)
|
||||||
sorted_ids = sorted(issue_ids)
|
try:
|
||||||
issues = _fetch_issues_chunked(redmine, sorted_ids)
|
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
|
||||||
|
|
||||||
|
# #61: задачи могли не вернуться из issue.filter (нет прав / удалены) —
|
||||||
|
# предупреждаем о выпавших задачах и потерянных часах, но строим отчёт
|
||||||
|
# по доступным данным.
|
||||||
|
returned_ids = {issue.id for issue in issues}
|
||||||
|
missing_ids = sorted(issue_ids - returned_ids)
|
||||||
|
if missing_ids:
|
||||||
|
lost_hours = sum(spent_time[iid] for iid in missing_ids)
|
||||||
|
print(
|
||||||
|
f"⚠️ {len(missing_ids)} issue(s) unavailable (no access or deleted; "
|
||||||
|
f"IDs: {', '.join(str(i) for i in missing_ids)}): "
|
||||||
|
f"{lost_hours:g}h excluded from the report.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
# Сопоставляем задачи с суммарным временем.
|
# Сопоставляем задачи с суммарным временем.
|
||||||
# Сортировка выполняется в report_builder.build_grouped_report,
|
# Сортировка выполняется в report_builder.build_grouped_report,
|
||||||
# здесь оставляем порядок API как есть.
|
# здесь оставляем порядок 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]
|
||||||
|
activity_breakdown = spent_by_activity.get(iid) if by_activity else None
|
||||||
|
result.append((issue, total_hours, activity_breakdown))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_open_issues(
|
||||||
|
user_id: int | str | None = None,
|
||||||
|
) -> list[tuple[Issue, date | None]]:
|
||||||
|
"""
|
||||||
|
Fetch open issues assigned to the given user across all projects,
|
||||||
|
paired with the due date of each issue's fixed version.
|
||||||
|
If user_id is None, uses current user.
|
||||||
|
Returns list of (issue, due_date) tuples in API order; sorting is done
|
||||||
|
in the builder layer. Raises RedmineAPIError on API/auth/network failures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
redmine = _create_redmine()
|
||||||
|
target_user_id = (
|
||||||
|
_resolve_user_id(redmine, user_id)
|
||||||
|
if user_id is not None
|
||||||
|
else _get_current_user_id(redmine)
|
||||||
|
)
|
||||||
|
# Без limit: python-redmine автопагинацией вычитывает все страницы,
|
||||||
|
# как в time_entry.filter выше.
|
||||||
|
issues = list(
|
||||||
|
redmine.issue.filter(assigned_to_id=target_user_id, status_id="open")
|
||||||
|
)
|
||||||
|
except RedmineAPIError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
||||||
|
|
||||||
|
# fixed_version внутри задачи содержит только id/name, поэтому due_date
|
||||||
|
# приходится добирать отдельным запросом на каждую уникальную версию (#71).
|
||||||
|
# Отказ загрузки одной версии не валидует выгрузку: её задачи получат None.
|
||||||
|
due_by_version: dict[int, date | None] = {}
|
||||||
|
for issue in issues:
|
||||||
|
version_ref = getattr(issue, "fixed_version", None)
|
||||||
|
if version_ref is None:
|
||||||
|
continue
|
||||||
|
version_id = getattr(version_ref, "id", None)
|
||||||
|
if version_id is None or version_id in due_by_version:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
version = redmine.version.get(version_id)
|
||||||
|
except Exception as exc: # noqa: BLE001 -- отказ загрузки одной версии не валидует выгрузку, деградируем к due_date=None
|
||||||
|
print(
|
||||||
|
f"⚠️ Could not load version {version_id}: {exc}. "
|
||||||
|
"Due date will be empty for its issues.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
due_by_version[version_id] = None
|
||||||
|
else:
|
||||||
|
due_by_version[version_id] = getattr(version, "due_date", None)
|
||||||
|
|
||||||
|
# Порядок задач сохраняем как в ответе API.
|
||||||
|
result: list[tuple[Issue, date | None]] = []
|
||||||
|
for issue in issues:
|
||||||
|
version_ref = getattr(issue, "fixed_version", None)
|
||||||
|
version_id = (
|
||||||
|
getattr(version_ref, "id", None) if version_ref is not None else None
|
||||||
|
)
|
||||||
|
due = due_by_version.get(version_id) if version_id is not None else None
|
||||||
|
result.append((issue, due))
|
||||||
|
return result
|
||||||
|
|||||||
@@ -1,19 +1,273 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import date, timedelta
|
import sys
|
||||||
from typing import Union
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(override=False)
|
from .yaml_config import check_file_permissions, resolve_env_vars
|
||||||
|
|
||||||
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_REDMINE_VERIFY: bool | str = True
|
||||||
FALSE_VALUES = {"0", "false", "no", "off"}
|
FALSE_VALUES = {"0", "false", "no", "off"}
|
||||||
TRUE_VALUES = {"1", "true", "yes", "on"}
|
TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
html: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
redmine_url: str = ""
|
||||||
|
redmine_api_key: str = ""
|
||||||
|
redmine_author: str = ""
|
||||||
|
redmine_verify: bool | str = DEFAULT_REDMINE_VERIFY
|
||||||
|
period_from: str = ""
|
||||||
|
output_dir: str = ""
|
||||||
|
output_filename: str = "{author}_{from}_{to}.{ext}"
|
||||||
|
output_default_format: str = "xlsx"
|
||||||
|
report_no_time: bool = False
|
||||||
|
report_status_translation: dict[str, str] = field(default_factory=dict)
|
||||||
|
email: EmailConfig = field(default_factory=EmailConfig)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_yaml(cls, path: 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",
|
||||||
|
"report",
|
||||||
|
"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_from=cls._resolve_str(raw, "period", "from"),
|
||||||
|
output_dir=cls._resolve_str(raw, "output", "dir"),
|
||||||
|
output_filename=cls._resolve_str(raw, "output", "filename")
|
||||||
|
or "{author}_{from}_{to}.{ext}",
|
||||||
|
output_default_format=cls._resolve_str(raw, "output", "default_format")
|
||||||
|
or "xlsx",
|
||||||
|
report_no_time=cls._resolve_bool(raw, "report", "no_time"),
|
||||||
|
report_status_translation=cls._resolve_str_dict(
|
||||||
|
raw, "report", "status_translation"
|
||||||
|
),
|
||||||
|
email=cls._resolve_email(raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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_str_dict(raw: dict, section: str, key: str) -> dict[str, str]:
|
||||||
|
"""Читает вложенный mapping строк из YAML-секции.
|
||||||
|
|
||||||
|
Не-mapping значение логируется с warning и игнорируется.
|
||||||
|
Ключи и значения приводятся к str, ${VAR} в значениях резолвится.
|
||||||
|
Записи со значением null или не-scalar (dict/list) пропускаются
|
||||||
|
с warning — иначе null стал бы строкой "None", а dict — repr.
|
||||||
|
"""
|
||||||
|
section_value = raw.get(section)
|
||||||
|
if not isinstance(section_value, dict):
|
||||||
|
return {}
|
||||||
|
value = section_value.get(key)
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
logger.warning(
|
||||||
|
"Config %s.%s must be a mapping, got %s — ignoring",
|
||||||
|
section,
|
||||||
|
key,
|
||||||
|
type(value).__name__,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for k, v in value.items():
|
||||||
|
if v is None or isinstance(v, (dict, list)):
|
||||||
|
logger.warning(
|
||||||
|
"Config %s.%s entry %r must be a scalar, got %s — skipping",
|
||||||
|
section,
|
||||||
|
key,
|
||||||
|
k,
|
||||||
|
type(v).__name__,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
result[str(k)] = resolve_env_vars(str(v))
|
||||||
|
return result
|
||||||
|
|
||||||
|
@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) -> bool | str:
|
||||||
|
"""Семантика verify_ssl, единая с REDMINE_VERIFY из env:
|
||||||
|
|
||||||
|
- true (bool или строка) → True — стандартная проверка TLS (requests);
|
||||||
|
- false (bool или строка) → False — проверка отключена;
|
||||||
|
- любая другая строка → путь к CA-bundle как есть.
|
||||||
|
"""
|
||||||
|
value = raw.get("redmine", {}).get("verify_ssl")
|
||||||
|
if value is None:
|
||||||
|
return DEFAULT_REDMINE_VERIFY
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.lower()
|
||||||
|
if normalized in FALSE_VALUES:
|
||||||
|
return False
|
||||||
|
if normalized in TRUE_VALUES:
|
||||||
|
return True
|
||||||
|
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),
|
||||||
|
html=cls._safe_bool(email_raw.get("html"), False),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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_url: str | None = None
|
||||||
_cli_api_key: str | None = None
|
_cli_api_key: str | None = None
|
||||||
|
_app: AppConfig | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_yaml(cls, path: str) -> None:
|
||||||
|
"""Загружает конфигурацию приложения.
|
||||||
|
|
||||||
|
Сначала подгружает .env из текущей директории (override=False:
|
||||||
|
переменные окружения не перебиваются), затем читает YAML-конфиг.
|
||||||
|
Не бросает исключений при отсутствии файла.
|
||||||
|
"""
|
||||||
|
load_dotenv(override=False)
|
||||||
|
cls._app = AppConfig.from_yaml(path)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load_config(cls, path: str) -> None:
|
def load_config(cls, path: str) -> None:
|
||||||
@@ -32,13 +286,23 @@ class Config:
|
|||||||
def get_redmine_url(cls) -> str:
|
def get_redmine_url(cls) -> str:
|
||||||
if cls._cli_url is not None:
|
if cls._cli_url is not None:
|
||||||
return cls._cli_url
|
return cls._cli_url
|
||||||
return os.getenv("REDMINE_URL", "").strip().rstrip("/")
|
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:
|
||||||
if cls._cli_api_key is not None:
|
if cls._cli_api_key is not None:
|
||||||
return cls._cli_api_key
|
return cls._cli_api_key
|
||||||
return os.getenv("REDMINE_API_KEY", "").strip()
|
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:
|
||||||
@@ -49,47 +313,125 @@ class Config:
|
|||||||
return os.getenv("REDMINE_PASSWORD", "").strip()
|
return os.getenv("REDMINE_PASSWORD", "").strip()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_redmine_verify(cls) -> Union[bool, str]:
|
def get_redmine_verify(cls) -> bool | str:
|
||||||
value = os.getenv("REDMINE_VERIFY", "").strip()
|
value = os.getenv("REDMINE_VERIFY", "").strip()
|
||||||
if 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_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
|
||||||
|
def get_report_no_time(cls) -> bool:
|
||||||
|
"""Возвращает report.no_time из YAML-конфига (по умолчанию False)."""
|
||||||
|
if cls._app:
|
||||||
|
return cls._app.report_no_time
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_status_translation(cls) -> dict[str, str]:
|
||||||
|
"""Полный словарь перевода статусов: встроенный + переопределения из YAML.
|
||||||
|
|
||||||
|
Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется.
|
||||||
|
Импорт ленивый: config.py не должен тянуть redminelib при импорте.
|
||||||
|
"""
|
||||||
|
from .report_builder import STATUS_TRANSLATION
|
||||||
|
|
||||||
|
overrides = cls._app.report_status_translation if cls._app else {}
|
||||||
|
return {**STATUS_TRANSLATION, **overrides}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_email_config(cls) -> "EmailConfig | None":
|
||||||
|
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""
|
||||||
|
if cls._app is None:
|
||||||
|
return None
|
||||||
|
email = cls._app.email
|
||||||
|
if not email.smtp.host:
|
||||||
|
return None
|
||||||
|
return email
|
||||||
|
|
||||||
@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..сегодня.
|
||||||
default_to_date = os.getenv("DEFAULT_TO_DATE", "").strip()
|
|
||||||
if default_from_date and default_to_date:
|
|
||||||
return f"{default_from_date}--{default_to_date}"
|
|
||||||
|
|
||||||
# fallback: текущий месяц
|
Приоритет начала: env FROM_DATE -> YAML period.from -> 1-е число
|
||||||
|
текущего месяца. Некорректная дата не роняет CLI -- логируется
|
||||||
|
warning и берётся текущий месяц.
|
||||||
|
"""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
start = today.replace(day=1)
|
raw_from = os.getenv("FROM_DATE", "").strip()
|
||||||
# последний день месяца: берём первое число следующего месяца и вычитаем день
|
if not raw_from and cls._app:
|
||||||
if today.month == 12:
|
raw_from = cls._app.period_from
|
||||||
next_month = today.replace(year=today.year + 1, month=1, day=1)
|
|
||||||
|
if raw_from:
|
||||||
|
try:
|
||||||
|
start = date.fromisoformat(raw_from)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning(
|
||||||
|
"Invalid FROM_DATE/period.from %r -- expected ISO date "
|
||||||
|
"(YYYY-MM-DD); using current month",
|
||||||
|
raw_from,
|
||||||
|
)
|
||||||
|
start = today.replace(day=1)
|
||||||
else:
|
else:
|
||||||
next_month = today.replace(month=today.month + 1, day=1)
|
start = today.replace(day=1)
|
||||||
end = next_month - timedelta(days=1)
|
|
||||||
return f"{start.isoformat()}--{end.isoformat()}"
|
# Будущая start инвертирует диапазон и ломает фильтр Redmine,
|
||||||
|
# поэтому сжимаем окно до сегодняшнего дня.
|
||||||
|
start = min(start, today)
|
||||||
|
return f"{start.isoformat()}--{today.isoformat()}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls) -> None:
|
def validate(cls) -> None:
|
||||||
if not cls.get_redmine_url():
|
url = cls.get_redmine_url()
|
||||||
|
if not url:
|
||||||
raise ValueError("REDMINE_URL is required (set via env or .env)")
|
raise ValueError("REDMINE_URL is required (set via env or .env)")
|
||||||
|
if not url.lower().startswith("https://"):
|
||||||
|
raise ValueError(
|
||||||
|
"REDMINE_URL must use HTTPS: the API key is sent in request "
|
||||||
|
"headers and requires TLS"
|
||||||
|
)
|
||||||
|
if cls.get_redmine_verify() is False:
|
||||||
|
print(
|
||||||
|
"⚠️ TLS certificate verification is disabled "
|
||||||
|
"(REDMINE_VERIFY=false / verify_ssl: false): connection is "
|
||||||
|
"vulnerable to MITM attacks",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
if cls.get_redmine_api_key():
|
if cls.get_redmine_api_key():
|
||||||
return
|
return
|
||||||
if not (cls.get_redmine_user() and cls.get_redmine_password()):
|
if not (cls.get_redmine_user() and cls.get_redmine_password()):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, List
|
from typing import Any
|
||||||
|
|
||||||
from ..types import ReportRow
|
from ..types import ReportRow
|
||||||
|
|
||||||
@@ -16,19 +16,17 @@ class Formatter(ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def format(self, rows: List[ReportRow]) -> Any:
|
def format(self, rows: list[ReportRow]) -> Any:
|
||||||
"""
|
"""
|
||||||
Форматирует список строк отчета в нужный формат.
|
Форматирует список строк отчета в нужный формат.
|
||||||
Для текстовых форматтеров возвращает str.
|
Для текстовых форматтеров возвращает str.
|
||||||
Для ODTFormatter возвращает объект OpenDocument.
|
Для ODTFormatter возвращает объект OpenDocument.
|
||||||
"""
|
"""
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||||
"""
|
"""
|
||||||
Сохраняет отформатированный отчет в файл по указанному пути.
|
Сохраняет отформатированный отчет в файл по указанному пути.
|
||||||
Для форматтеров, которые не поддерживают сохранение (например, консольные),
|
Для форматтеров, которые не поддерживают сохранение (например, консольные),
|
||||||
бросает NotImplementedError.
|
бросает NotImplementedError.
|
||||||
"""
|
"""
|
||||||
pass
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from tabulate import tabulate
|
from tabulate import tabulate
|
||||||
|
|
||||||
from ..types import ReportRow
|
from ..types import ReportRow
|
||||||
@@ -9,21 +7,22 @@ from .base import Formatter
|
|||||||
class TableFormatter(Formatter):
|
class TableFormatter(Formatter):
|
||||||
"""Форматтер для вывода красивой таблицы в консоль."""
|
"""Форматтер для вывода красивой таблицы в консоль."""
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: list[ReportRow]) -> str:
|
||||||
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
|
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
time_text = r["time_text"].replace("\n", " / ")
|
||||||
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")
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||||
# Консольные форматтеры не умеют сохранять в файл напрямую.
|
# Консольные форматтеры не умеют сохранять в файл напрямую.
|
||||||
# Это делается в CLI.
|
# Это делается в CLI.
|
||||||
raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.")
|
raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.")
|
||||||
@@ -32,16 +31,17 @@ class TableFormatter(Formatter):
|
|||||||
class CompactFormatter(Formatter):
|
class CompactFormatter(Formatter):
|
||||||
"""Форматтер для компактного вывода в консоль."""
|
"""Форматтер для компактного вывода в консоль."""
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: list[ReportRow]) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
time_text = r["time_text"].replace("\n", " / ")
|
||||||
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)
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||||
# Консольные форматтеры не умеют сохранять в файл напрямую.
|
# Консольные форматтеры не умеют сохранять в файл напрямую.
|
||||||
# Это делается в CLI.
|
# Это делается в CLI.
|
||||||
raise NotImplementedError("CompactFormatter не поддерживает сохранение в файл.")
|
raise NotImplementedError("CompactFormatter не поддерживает сохранение в файл.")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from ..types import ReportRow
|
from ..types import ReportRow
|
||||||
from .base import Formatter
|
from .base import Formatter
|
||||||
@@ -15,27 +14,32 @@ class CSVFormatter(Formatter):
|
|||||||
корректного отображения кириллицы в Microsoft Excel (#26).
|
корректного отображения кириллицы в Microsoft Excel (#26).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, **_kwargs):
|
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-sig", newline="") as f:
|
with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from typing import Dict, Optional, Type
|
|
||||||
|
|
||||||
from .base import Formatter
|
from .base import Formatter
|
||||||
from .console import CompactFormatter, TableFormatter
|
from .console import CompactFormatter, TableFormatter
|
||||||
from .csv import CSVFormatter
|
from .csv import CSVFormatter
|
||||||
@@ -11,7 +9,7 @@ from .xlsx import XLSXFormatter
|
|||||||
# Словарь для сопоставления расширений файлов с классами форматтеров.
|
# Словарь для сопоставления расширений файлов с классами форматтеров.
|
||||||
# ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
|
# ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
|
||||||
# odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
|
# odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
|
||||||
FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
FORMATTER_MAP: dict[str, type[Formatter]] = {
|
||||||
".csv": CSVFormatter,
|
".csv": CSVFormatter,
|
||||||
".json": JSONFormatter,
|
".json": JSONFormatter,
|
||||||
".md": MarkdownFormatter,
|
".md": MarkdownFormatter,
|
||||||
@@ -21,13 +19,13 @@ FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
|||||||
|
|
||||||
|
|
||||||
# Словарь для сопоставления типа вывода (консоль) с классами форматтеров
|
# Словарь для сопоставления типа вывода (консоль) с классами форматтеров
|
||||||
CONSOLE_FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
CONSOLE_FORMATTER_MAP: dict[str, type[Formatter]] = {
|
||||||
"table": TableFormatter,
|
"table": TableFormatter,
|
||||||
"compact": CompactFormatter,
|
"compact": CompactFormatter,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]:
|
def get_formatter_by_extension(extension: str, **kwargs) -> Formatter | None:
|
||||||
"""
|
"""
|
||||||
Возвращает экземпляр форматтера по расширению файла.
|
Возвращает экземпляр форматтера по расширению файла.
|
||||||
Ключевые аргументы (**kwargs) передаются в конструктор форматтера.
|
Ключевые аргументы (**kwargs) передаются в конструктор форматтера.
|
||||||
@@ -51,7 +49,7 @@ def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_console_formatter(formatter_type: str) -> Optional[Formatter]:
|
def get_console_formatter(formatter_type: str) -> Formatter | None:
|
||||||
"""
|
"""
|
||||||
Возвращает экземпляр консольного форматтера по его типу.
|
Возвращает экземпляр консольного форматтера по его типу.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from html import escape
|
from html import escape
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from ..report_builder import group_rows_by_project_and_version
|
from ..report_builder import group_rows_by_project_and_version
|
||||||
from ..types import ReportRow
|
from ..types import ReportRow
|
||||||
@@ -12,7 +11,7 @@ class HTMLFormatter(Formatter):
|
|||||||
def __init__(self, **_kwargs):
|
def __init__(self, **_kwargs):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: list[ReportRow]) -> str:
|
||||||
projects = group_rows_by_project_and_version(rows)
|
projects = group_rows_by_project_and_version(rows)
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
@@ -48,7 +47,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>")
|
||||||
|
|
||||||
# Ячейка "Проект" - только в первой строке проекта
|
# Ячейка "Проект" - только в первой строке проекта
|
||||||
@@ -79,7 +78,7 @@ class HTMLFormatter(Formatter):
|
|||||||
lines.append("</html>")
|
lines.append("</html>")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||||
content = self.format(rows)
|
content = self.format(rows)
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from ..types import ReportRow
|
from ..types import ReportRow
|
||||||
from .base import Formatter
|
from .base import Formatter
|
||||||
@@ -11,9 +10,10 @@ class JSONFormatter(Formatter):
|
|||||||
def __init__(self, **_kwargs):
|
def __init__(self, **_kwargs):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: list[ReportRow]) -> str:
|
||||||
data = [
|
data = []
|
||||||
{
|
for r in rows:
|
||||||
|
item = {
|
||||||
"project": r["project"],
|
"project": r["project"],
|
||||||
"version": r["version"],
|
"version": r["version"],
|
||||||
"issue_id": r["issue_id"],
|
"issue_id": r["issue_id"],
|
||||||
@@ -21,11 +21,13 @@ class JSONFormatter(Formatter):
|
|||||||
"status": r["status_ru"],
|
"status": r["status_ru"],
|
||||||
"time": r["time_text"],
|
"time": r["time_text"],
|
||||||
}
|
}
|
||||||
for r in rows
|
activities = r.get("activities")
|
||||||
]
|
if activities:
|
||||||
|
item["activities"] = activities
|
||||||
|
data.append(item)
|
||||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||||
content = self.format(rows)
|
content = self.format(rows)
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from ..types import ReportRow
|
from ..types import ReportRow
|
||||||
from .base import Formatter
|
from .base import Formatter
|
||||||
|
|
||||||
@@ -14,7 +12,7 @@ class MarkdownFormatter(Formatter):
|
|||||||
def __init__(self, **_kwargs):
|
def __init__(self, **_kwargs):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: list[ReportRow]) -> str:
|
||||||
lines = [
|
lines = [
|
||||||
"| Проект | Версия | Задача | Статус | Затрачено |",
|
"| Проект | Версия | Задача | Статус | Затрачено |",
|
||||||
"|--------|--------|--------|--------|-----------|",
|
"|--------|--------|--------|--------|-----------|",
|
||||||
@@ -30,7 +28,7 @@ class MarkdownFormatter(Formatter):
|
|||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||||
content = self.format(rows)
|
content = self.format(rows)
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from importlib import resources
|
from importlib import resources
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from odf.opendocument import OpenDocument, load
|
from odf.opendocument import OpenDocument, load
|
||||||
from odf.style import Style, TableCellProperties, TableColumnProperties
|
from odf.style import Style, TableCellProperties, TableColumnProperties
|
||||||
@@ -15,7 +14,9 @@ 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
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Инициализирует форматтер с параметрами для шапки отчета.
|
Инициализирует форматтер с параметрами для шапки отчета.
|
||||||
"""
|
"""
|
||||||
@@ -23,14 +24,25 @@ class ODTFormatter(Formatter):
|
|||||||
self.from_date = from_date
|
self.from_date = from_date
|
||||||
self.to_date = to_date
|
self.to_date = to_date
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> OpenDocument:
|
def format(self, rows: list[ReportRow]) -> OpenDocument:
|
||||||
"""
|
"""
|
||||||
Форматирует данные в объект OpenDocument.
|
Форматирует данные в объект OpenDocument.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
# Заголовок
|
# Заголовок
|
||||||
@@ -45,7 +57,9 @@ class ODTFormatter(Formatter):
|
|||||||
# Стиль ячеек
|
# Стиль ячеек
|
||||||
cell_style_name = "TableCellStyle"
|
cell_style_name = "TableCellStyle"
|
||||||
cell_style = Style(name=cell_style_name, family="table-cell")
|
cell_style = Style(name=cell_style_name, family="table-cell")
|
||||||
cell_props = TableCellProperties(padding="0.04in", border="0.05pt solid #000000")
|
cell_props = TableCellProperties(
|
||||||
|
padding="0.04in", border="0.05pt solid #000000"
|
||||||
|
)
|
||||||
cell_style.addElement(cell_props)
|
cell_style.addElement(cell_props)
|
||||||
doc.automaticstyles.addElement(cell_style)
|
doc.automaticstyles.addElement(cell_style)
|
||||||
|
|
||||||
@@ -93,7 +107,9 @@ class ODTFormatter(Formatter):
|
|||||||
# в остальных — covered-cell для валидности ODF (#13)
|
# в остальных — 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)
|
||||||
@@ -104,7 +120,9 @@ class ODTFormatter(Formatter):
|
|||||||
# в остальных — covered-cell для валидности ODF (#13)
|
# в остальных — 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)
|
||||||
|
)
|
||||||
p = P(stylename=para_style_name, text=version)
|
p = P(stylename=para_style_name, text=version)
|
||||||
cell_version.addElement(p)
|
cell_version.addElement(p)
|
||||||
row.addElement(cell_version)
|
row.addElement(cell_version)
|
||||||
@@ -125,8 +143,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)
|
||||||
@@ -149,7 +171,7 @@ class ODTFormatter(Formatter):
|
|||||||
|
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||||
"""
|
"""
|
||||||
Сохраняет сформированный документ в файл.
|
Сохраняет сформированный документ в файл.
|
||||||
"""
|
"""
|
||||||
|
|||||||
44
redmine_reporter/formatters/task_table.py
Normal file
44
redmine_reporter/formatters/task_table.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from tabulate import tabulate
|
||||||
|
|
||||||
|
from ..types import TaskRow
|
||||||
|
|
||||||
|
|
||||||
|
class TaskTableFormatter:
|
||||||
|
"""Консольный форматтер таблицы открытых задач пользователя (режим --my-tasks).
|
||||||
|
|
||||||
|
Не входит в фабрику файловых форматтеров: таблица всегда печатается
|
||||||
|
в stdout, поэтому сохранение в файл не поддержано.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str) -> None:
|
||||||
|
self.base_url = base_url
|
||||||
|
|
||||||
|
def _hyperlink(self, issue_id: int) -> str:
|
||||||
|
# OSC 8-гиперссылка: номер задачи остаётся видимым терминалом без
|
||||||
|
# поддержки ссылок, а поддерживающие делают его кликабельным.
|
||||||
|
url = f"{self.base_url.rstrip('/')}/issues/{issue_id}"
|
||||||
|
return f"\x1b]8;;{url}\x1b\\{issue_id}\x1b]8;;\x1b\\"
|
||||||
|
|
||||||
|
def format(self, rows: list[TaskRow]) -> str:
|
||||||
|
table_rows = [["ID", "Тема", "Статус", "Приор.", "Версия", "Срок"]]
|
||||||
|
for row in rows:
|
||||||
|
due_text = row["due_text"]
|
||||||
|
if row["overdue"]:
|
||||||
|
due_text = f"{due_text} (!)"
|
||||||
|
table_rows.append(
|
||||||
|
[
|
||||||
|
self._hyperlink(row["issue_id"]),
|
||||||
|
row["subject"],
|
||||||
|
row["status_ru"],
|
||||||
|
row["priority"],
|
||||||
|
row["version_name"] or "-",
|
||||||
|
due_text or "-",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return tabulate(table_rows, headers="firstrow", tablefmt="simple")
|
||||||
|
|
||||||
|
def save(self, rows: list[TaskRow], output_path: str) -> None:
|
||||||
|
# Консольные форматтеры не умеют сохранять в файл напрямую.
|
||||||
|
raise NotImplementedError(
|
||||||
|
"TaskTableFormatter не поддерживает сохранение в файл."
|
||||||
|
)
|
||||||
@@ -1,42 +1,238 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
from openpyxl.styles import Font
|
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 ..types import ReportRow
|
||||||
|
from ..utils import hours_to_human
|
||||||
from .base import Formatter
|
from .base import Formatter
|
||||||
|
|
||||||
|
|
||||||
class XLSXFormatter(Formatter):
|
class XLSXFormatter(Formatter):
|
||||||
"""Форматтер для экспорта отчёта в Excel (.xlsx)."""
|
"""Форматтер для экспорта отчёта в Excel (.xlsx).
|
||||||
|
|
||||||
def __init__(self, **_kwargs):
|
Использует группировку по проекту и версии: объединяет ячейки,
|
||||||
|
добавляет итоги по группам, закрепляет заголовок, включает автофильтр
|
||||||
|
и числовой столбец с часами для удобного суммирования.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_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__()
|
super().__init__()
|
||||||
|
self.no_time = no_time
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> Workbook:
|
def format(self, rows: list[ReportRow]) -> Workbook:
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "Report"
|
ws.title = "Report"
|
||||||
|
|
||||||
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]
|
headers = [
|
||||||
|
"Project",
|
||||||
|
"Version",
|
||||||
|
"Issue ID",
|
||||||
|
"Subject",
|
||||||
|
"Status",
|
||||||
|
"Hours",
|
||||||
|
"Spent Time",
|
||||||
|
]
|
||||||
ws.append(headers)
|
ws.append(headers)
|
||||||
for cell in ws[1]:
|
self._style_header_row(ws, headers)
|
||||||
cell.font = Font(bold=True)
|
|
||||||
|
|
||||||
for r in rows:
|
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(
|
ws.append(
|
||||||
[
|
[
|
||||||
r["project"],
|
"Total",
|
||||||
r["version"],
|
"",
|
||||||
r["issue_id"],
|
"",
|
||||||
r["subject"],
|
"",
|
||||||
r["status_ru"],
|
"",
|
||||||
r["time_text"],
|
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
|
return wb
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||||
wb = self.format(rows)
|
wb = self.format(rows)
|
||||||
wb.save(output_path)
|
wb.save(output_path)
|
||||||
|
|
||||||
|
def _style_header_row(self, ws: Worksheet, headers: list[str]) -> None:
|
||||||
|
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) or 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}"
|
||||||
|
|||||||
151
redmine_reporter/mailer.py
Normal file
151
redmine_reporter/mailer.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Отправка сгенерированного отчёта по email через SMTP."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
from email.mime.application import MIMEApplication
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
from .client import RedmineAPIError
|
||||||
|
from .config import EmailConfig
|
||||||
|
from .types import ReportRow
|
||||||
|
|
||||||
|
SMTP_TIMEOUT = 30
|
||||||
|
|
||||||
|
MIME_TYPES: dict[str, str] = {
|
||||||
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".odt": "application/vnd.oasis.opendocument.text",
|
||||||
|
".csv": "text/csv",
|
||||||
|
".html": "text/html",
|
||||||
|
".json": "application/json",
|
||||||
|
".md": "text/markdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_mime_type(file_path: str) -> str:
|
||||||
|
"""Определяет MIME-тип по расширению файла."""
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
return MIME_TYPES.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
def _utf8_text_part(text: str, subtype: str = "plain") -> MIMEText:
|
||||||
|
"""Создаёт текстовую MIME-часть с 8bit UTF-8 телом.
|
||||||
|
|
||||||
|
Кодировка применяется точечно к части письма, без глобальной мутации
|
||||||
|
реестра email.charset: payload хранится как UTF-8 (surrogateescape),
|
||||||
|
а Content-Transfer-Encoding выставляется в 8bit, чтобы не-ASCII текст
|
||||||
|
(например, русский) передавался литерально, а не в base64.
|
||||||
|
"""
|
||||||
|
part = MIMEText("", subtype, "utf-8")
|
||||||
|
part.set_payload(text.encode("utf-8").decode("ascii", "surrogateescape"))
|
||||||
|
part.replace_header("Content-Transfer-Encoding", "8bit")
|
||||||
|
return part
|
||||||
|
|
||||||
|
|
||||||
|
def _build_html_body(rows: list[ReportRow]) -> str:
|
||||||
|
"""Генерирует HTML-версию тела письма через HTMLFormatter."""
|
||||||
|
from .formatters.html import HTMLFormatter
|
||||||
|
|
||||||
|
formatter = HTMLFormatter()
|
||||||
|
return formatter.format(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(
|
||||||
|
email_config: EmailConfig,
|
||||||
|
file_path: str,
|
||||||
|
author: str,
|
||||||
|
period: str,
|
||||||
|
rows: list[ReportRow],
|
||||||
|
) -> MIMEMultipart:
|
||||||
|
"""Формирует MIME-письмо с подстановками, телами и вложением."""
|
||||||
|
subject = email_config.subject.replace("{author}", author).replace(
|
||||||
|
"{period}", period
|
||||||
|
)
|
||||||
|
body = email_config.body_text.replace("{author}", author).replace(
|
||||||
|
"{period}", period
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = email_config.from_
|
||||||
|
msg["To"] = ", ".join(email_config.to)
|
||||||
|
if email_config.cc:
|
||||||
|
msg["Cc"] = ", ".join(email_config.cc)
|
||||||
|
|
||||||
|
# Тела письма: plain-text всегда, HTML по флагу
|
||||||
|
body_container = MIMEMultipart("alternative")
|
||||||
|
body_container.attach(_utf8_text_part(body))
|
||||||
|
|
||||||
|
if email_config.html:
|
||||||
|
html_body = _build_html_body(rows)
|
||||||
|
body_container.attach(_utf8_text_part(html_body, "html"))
|
||||||
|
|
||||||
|
msg.attach(body_container)
|
||||||
|
|
||||||
|
if email_config.attach:
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as fh:
|
||||||
|
attachment = MIMEApplication(fh.read())
|
||||||
|
except OSError:
|
||||||
|
raise RedmineAPIError(
|
||||||
|
f"Не удалось прочитать файл отчёта: {file_path}"
|
||||||
|
) from None
|
||||||
|
attachment.add_header(
|
||||||
|
"Content-Disposition",
|
||||||
|
"attachment",
|
||||||
|
filename=os.path.basename(file_path),
|
||||||
|
)
|
||||||
|
mime_type = _resolve_mime_type(file_path)
|
||||||
|
attachment.set_type(mime_type)
|
||||||
|
msg.attach(attachment)
|
||||||
|
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def send_report(
|
||||||
|
email_config: EmailConfig,
|
||||||
|
file_path: str,
|
||||||
|
author: str,
|
||||||
|
period: str,
|
||||||
|
rows: list[ReportRow],
|
||||||
|
) -> None:
|
||||||
|
"""Отправляет сгенерированный отчёт по email через SMTP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_config: Настройки SMTP и письма.
|
||||||
|
file_path: Путь к файлу отчёта для вложения.
|
||||||
|
author: Имя автора (для подстановки в тему/тело).
|
||||||
|
period: Строка периода (для подстановки в тему/тело).
|
||||||
|
rows: Строки отчёта (для HTML-версии тела письма).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RedmineAPIError: При любой ошибке соединения или отправки.
|
||||||
|
"""
|
||||||
|
smtp_cfg = email_config.smtp
|
||||||
|
msg = build_message(email_config, file_path, author, period, rows)
|
||||||
|
all_recipients = (
|
||||||
|
list(email_config.to) + list(email_config.cc) + list(email_config.bcc)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=SMTP_TIMEOUT) as server:
|
||||||
|
if smtp_cfg.tls:
|
||||||
|
server.starttls()
|
||||||
|
if smtp_cfg.user:
|
||||||
|
server.login(smtp_cfg.user, smtp_cfg.password)
|
||||||
|
|
||||||
|
server.send_message(
|
||||||
|
msg, from_addr=email_config.from_, to_addrs=all_recipients
|
||||||
|
)
|
||||||
|
except smtplib.SMTPAuthenticationError:
|
||||||
|
raise RedmineAPIError(
|
||||||
|
"Ошибка аутентификации SMTP. Проверьте логин и пароль."
|
||||||
|
) from None
|
||||||
|
except TimeoutError:
|
||||||
|
raise RedmineAPIError("Таймаут соединения с SMTP-сервером.") from None
|
||||||
|
except smtplib.SMTPException as exc:
|
||||||
|
raise RedmineAPIError(f"Ошибка отправки письма: {exc}") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise RedmineAPIError(
|
||||||
|
f"Не удалось подключиться к SMTP-серверу {smtp_cfg.host}:{smtp_cfg.port}"
|
||||||
|
) from exc
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Dict, List, Tuple, cast
|
from typing import cast
|
||||||
|
|
||||||
from redminelib.resources import Issue
|
from redminelib.resources import Issue
|
||||||
|
|
||||||
@@ -22,34 +22,66 @@ 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, dict[str, float] | None]],
|
||||||
fill_time: bool = True,
|
fill_time: bool = True,
|
||||||
) -> List[ReportRow]:
|
by_activity: bool = False,
|
||||||
|
status_translation: dict[str, str] | None = None,
|
||||||
|
) -> list[ReportRow]:
|
||||||
"""
|
"""
|
||||||
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
||||||
с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений).
|
с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений).
|
||||||
|
|
||||||
Предусловие: issue_hours должен быть отсортирован по (project, version).
|
Предусловие: issue_hours должен быть отсортирован по (project, version).
|
||||||
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
|
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
|
||||||
|
|
||||||
|
status_translation: перевод статусов; None — встроенный STATUS_TRANSLATION.
|
||||||
|
Переданный словарь ЗАМЕНЯЕТ встроенный полностью (без merge): статус,
|
||||||
|
отсутствующий в нём, выводится как есть (passthrough); пустой dict —
|
||||||
|
все статусы выводятся как есть.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
||||||
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id))
|
issue_hours = sorted(
|
||||||
|
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
|
||||||
|
)
|
||||||
|
|
||||||
rows: List[ReportRow] = []
|
translation = (
|
||||||
|
status_translation if status_translation is not None else STATUS_TRANSLATION
|
||||||
|
)
|
||||||
|
|
||||||
|
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: dict[str, float] | None = rest[0] if rest else None
|
||||||
project = str(issue.project)
|
project = str(issue.project)
|
||||||
version = get_version(issue)
|
version = get_version(issue)
|
||||||
status_en = str(issue.status)
|
status_en = str(issue.status)
|
||||||
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
|
status_ru = 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 ""
|
||||||
|
)
|
||||||
|
|
||||||
rows.append(
|
rows.append(
|
||||||
cast(
|
cast(
|
||||||
@@ -64,6 +96,7 @@ def build_grouped_report(
|
|||||||
"status_ru": status_ru,
|
"status_ru": status_ru,
|
||||||
"time_text": time_text,
|
"time_text": time_text,
|
||||||
"hours": round(hours, 2),
|
"hours": round(hours, 2),
|
||||||
|
"activities": activities,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -74,11 +107,15 @@ def build_grouped_report(
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]:
|
def calculate_summary(
|
||||||
"""Возвращает сводку: общее время, время по проектам и версиям."""
|
rows: list[ReportRow],
|
||||||
|
by_activity: bool = False,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""Возвращает сводку: общее время, время по проектам, версиям и активностям."""
|
||||||
total = 0.0
|
total = 0.0
|
||||||
by_project: Dict[str, float] = {}
|
by_project: dict[str, float] = {}
|
||||||
by_project_version: Dict[str, float] = {}
|
by_project_version: dict[str, float] = {}
|
||||||
|
by_activity_name: dict[str, float] = {}
|
||||||
|
|
||||||
for r in rows:
|
for r in rows:
|
||||||
hours = r.get("hours", 0.0)
|
hours = r.get("hours", 0.0)
|
||||||
@@ -87,16 +124,28 @@ def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]:
|
|||||||
key = f"{r['project']}::{r['version']}"
|
key = f"{r['project']}::{r['version']}"
|
||||||
by_project_version[key] = by_project_version.get(key, 0.0) + hours
|
by_project_version[key] = by_project_version.get(key, 0.0) + hours
|
||||||
|
|
||||||
return {
|
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),
|
"total": round(total, 2),
|
||||||
**{f"project:{k}": round(v, 2) for k, v in by_project.items()},
|
**{f"project:{k}": round(v, 2) for k, v in by_project.items()},
|
||||||
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
|
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
|
||||||
}
|
}
|
||||||
|
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(
|
def group_rows_by_project_and_version(
|
||||||
rows: List[ReportRow],
|
rows: list[ReportRow],
|
||||||
) -> Dict[str, Dict[str, List[ReportRow]]]:
|
) -> dict[str, dict[str, list[ReportRow]]]:
|
||||||
"""
|
"""
|
||||||
Группирует плоский список строк отчёта в иерархию project → version → [rows].
|
Группирует плоский список строк отчёта в иерархию project → version → [rows].
|
||||||
|
|
||||||
@@ -104,7 +153,7 @@ def group_rows_by_project_and_version(
|
|||||||
Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+).
|
Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+).
|
||||||
Используется форматтерами HTML и ODT для объединения ячеек.
|
Используется форматтерами HTML и ODT для объединения ячеек.
|
||||||
"""
|
"""
|
||||||
projects: Dict[str, Dict[str, List[ReportRow]]] = {}
|
projects: dict[str, dict[str, list[ReportRow]]] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
project = r["project"]
|
project = r["project"]
|
||||||
version = r["version"]
|
version = r["version"]
|
||||||
|
|||||||
149
redmine_reporter/task_list.py
Normal file
149
redmine_reporter/task_list.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from redminelib.resources import Issue
|
||||||
|
|
||||||
|
from .types import TaskRow, TaskSummary
|
||||||
|
from .utils import get_version
|
||||||
|
|
||||||
|
# В этом Redmine ровно пять приоритетов P1..P5; меньший номер -- срочнее.
|
||||||
|
PRIORITY_RANK: dict[str, int] = {"P1": 1, "P2": 2, "P3": 3, "P4": 4, "P5": 5}
|
||||||
|
|
||||||
|
# Ранг для неизвестного имени приоритета: такие задачи уходят в самый конец.
|
||||||
|
LOWEST_PRIORITY_RANK = len(PRIORITY_RANK) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def priority_rank(name: str) -> int:
|
||||||
|
"""Возвращает ранг приоритета; неизвестное имя получает низший ранг."""
|
||||||
|
return PRIORITY_RANK.get(name, LOWEST_PRIORITY_RANK)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_version_name(issue: Issue) -> str:
|
||||||
|
"""Имя версии задачи или пустая строка, если версия не назначена."""
|
||||||
|
return get_version(issue, default="")
|
||||||
|
|
||||||
|
|
||||||
|
# "Wishlist" -- постоянная бакет-версия без срока (личный бакет Wendy, факт
|
||||||
|
# из трекера): задачи в ней -- отложенные идеи, создающие мусорный фон в
|
||||||
|
# персональной выборке, поэтому режим --my-tasks исключает их целиком --
|
||||||
|
# из таблицы и из саммари. Сравнение имени точное, без нормализации регистра.
|
||||||
|
EXCLUDED_VERSION_NAMES: frozenset[str] = frozenset({"Wishlist"})
|
||||||
|
|
||||||
|
|
||||||
|
def filter_excluded_versions(
|
||||||
|
items: list[tuple[Issue, date | None]],
|
||||||
|
) -> list[tuple[Issue, date | None]]:
|
||||||
|
"""Убирает задачи версий-исключений из выборки --my-tasks.
|
||||||
|
|
||||||
|
Доступ к fixed_version.name getattr-безопасный: задача без версии или
|
||||||
|
с версией без имени остаётся в выборке. Даты в парах сохраняются как есть.
|
||||||
|
"""
|
||||||
|
kept: list[tuple[Issue, date | None]] = []
|
||||||
|
for item in items:
|
||||||
|
version = getattr(item[0], "fixed_version", None)
|
||||||
|
name = getattr(version, "name", None) if version is not None else None
|
||||||
|
if name and str(name) in EXCLUDED_VERSION_NAMES:
|
||||||
|
continue
|
||||||
|
kept.append(item)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_key(item: tuple[Issue, date | None]) -> tuple[int, bool, date, int]:
|
||||||
|
"""Ключ сортировки: приоритет, затем срок по возрастанию, затем id.
|
||||||
|
|
||||||
|
Задачи без срока уходят в конец своей приоритетной группы: флаг
|
||||||
|
(due is None) сортирует False раньше True, а date.max служит нейтральным
|
||||||
|
значением, чтобы сравнение дат осталось корректным.
|
||||||
|
"""
|
||||||
|
issue, due = item
|
||||||
|
rank = priority_rank(str(issue.priority.name))
|
||||||
|
return (rank, due is None, due if due is not None else date.max, issue.id)
|
||||||
|
|
||||||
|
|
||||||
|
def build_task_rows(
|
||||||
|
items: list[tuple[Issue, date | None]],
|
||||||
|
status_translation: dict[str, str],
|
||||||
|
today: date | None = None,
|
||||||
|
) -> list[TaskRow]:
|
||||||
|
"""Строит отсортированный список строк режима --my-tasks.
|
||||||
|
|
||||||
|
items -- пары (задача, срок версии или None). today параметризован
|
||||||
|
для тестируемости; по умолчанию берётся текущая дата.
|
||||||
|
"""
|
||||||
|
if today is None:
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
rows: list[TaskRow] = []
|
||||||
|
for issue, due in sorted(items, key=_sort_key):
|
||||||
|
status_name = str(issue.status)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"issue_id": issue.id,
|
||||||
|
"subject": str(issue.subject),
|
||||||
|
"status_ru": status_translation.get(status_name, status_name),
|
||||||
|
"priority": str(issue.priority.name),
|
||||||
|
"version_name": _issue_version_name(issue),
|
||||||
|
"due_text": due.isoformat() if due is not None else "",
|
||||||
|
"overdue": due is not None and due < today,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_task_summary(
|
||||||
|
items: list[tuple[Issue, date | None]],
|
||||||
|
today: date | None = None,
|
||||||
|
) -> TaskSummary:
|
||||||
|
"""Считает саммари по списку пар (задача, срок версии или None)."""
|
||||||
|
if today is None:
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
overdue = 0
|
||||||
|
no_version = 0
|
||||||
|
no_due = 0
|
||||||
|
spent_hours = 0.0
|
||||||
|
estimated_hours = 0.0
|
||||||
|
estimated_count = 0
|
||||||
|
|
||||||
|
for issue, due in items:
|
||||||
|
if due is not None and due < today:
|
||||||
|
overdue += 1
|
||||||
|
|
||||||
|
version = getattr(issue, "fixed_version", None)
|
||||||
|
if version is None:
|
||||||
|
no_version += 1
|
||||||
|
elif due is None:
|
||||||
|
no_due += 1
|
||||||
|
|
||||||
|
raw_spent = getattr(issue, "spent_hours", None)
|
||||||
|
if raw_spent is not None:
|
||||||
|
spent_hours += float(raw_spent)
|
||||||
|
|
||||||
|
raw_estimated = getattr(issue, "estimated_hours", None)
|
||||||
|
if raw_estimated is not None:
|
||||||
|
estimated_hours += float(raw_estimated)
|
||||||
|
estimated_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": len(items),
|
||||||
|
"overdue": overdue,
|
||||||
|
"no_version": no_version,
|
||||||
|
"no_due": no_due,
|
||||||
|
"spent_hours": spent_hours,
|
||||||
|
"estimated_hours": estimated_hours,
|
||||||
|
"estimated_count": estimated_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_summary_line(s: TaskSummary) -> str:
|
||||||
|
"""Форматирует однострочное саммари; блок оценки выводится при наличии данных."""
|
||||||
|
line = (
|
||||||
|
f"Открытых: {s['total']} | просроченных: {s['overdue']}"
|
||||||
|
f" | без версии: {s['no_version']} | без срока: {s['no_due']}"
|
||||||
|
f" | потрачено: {s['spent_hours']:g}h"
|
||||||
|
)
|
||||||
|
if s["estimated_count"] > 0:
|
||||||
|
line += (
|
||||||
|
f" | оценка: {s['estimated_hours']:g}h"
|
||||||
|
f" (по {s['estimated_count']} из {s['total']})"
|
||||||
|
)
|
||||||
|
return line
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
class ReportRow(TypedDict):
|
class ReportRowBase(TypedDict):
|
||||||
"""Строка итогового отчёта."""
|
"""Строка итогового отчёта."""
|
||||||
|
|
||||||
project: str
|
project: str
|
||||||
@@ -13,3 +13,33 @@ class ReportRow(TypedDict):
|
|||||||
status_ru: str
|
status_ru: str
|
||||||
time_text: str
|
time_text: str
|
||||||
hours: float
|
hours: float
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRow(ReportRowBase, total=False):
|
||||||
|
"""Строка итогового отчёта с опциональной разбивкой по активностям."""
|
||||||
|
|
||||||
|
activities: dict[str, float] | None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRow(TypedDict):
|
||||||
|
"""Строка списка открытых задач пользователя (режим --my-tasks)."""
|
||||||
|
|
||||||
|
issue_id: int
|
||||||
|
subject: str
|
||||||
|
status_ru: str
|
||||||
|
priority: str
|
||||||
|
version_name: str
|
||||||
|
due_text: str
|
||||||
|
overdue: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TaskSummary(TypedDict):
|
||||||
|
"""Саммари списка открытых задач пользователя."""
|
||||||
|
|
||||||
|
total: int
|
||||||
|
overdue: int
|
||||||
|
no_version: int
|
||||||
|
no_due: int
|
||||||
|
spent_hours: float
|
||||||
|
estimated_hours: float
|
||||||
|
estimated_count: int
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ def get_month_name_from_range(from_date: str, to_date: str) -> str:
|
|||||||
return months[end.month]
|
return months[end.month]
|
||||||
|
|
||||||
|
|
||||||
def get_version(issue) -> str:
|
def get_version(issue, default: str = "<N/A>") -> str:
|
||||||
"""Возвращает версию задачи или '<N/A>', если не задана."""
|
"""Возвращает версию задачи или default, если не задана."""
|
||||||
version = getattr(issue, "fixed_version", None)
|
version = getattr(issue, "fixed_version", None)
|
||||||
if version is None:
|
if version is None:
|
||||||
return "<N/A>"
|
return default
|
||||||
name = getattr(version, "name", None)
|
name = getattr(version, "name", None)
|
||||||
return str(name) if name else str(version)
|
return str(name) if name else str(version)
|
||||||
|
|
||||||
|
|||||||
178
redmine_reporter/yaml_config.py
Normal file
178
redmine_reporter/yaml_config.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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) -> None:
|
||||||
|
"""Save committed period start to YAML config file.
|
||||||
|
|
||||||
|
Writes period.from only: all other sections and keys stay untouched.
|
||||||
|
Creates the config file (and its parent directory) 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["from"] = from_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)
|
||||||
2024
tests/test_cli.py
2024
tests/test_cli.py
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
|
from datetime import date
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from redmine_reporter.client import fetch_issues_with_spent_time
|
import pytest
|
||||||
|
|
||||||
|
from redmine_reporter.client import fetch_issues_with_spent_time, fetch_open_issues
|
||||||
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
|
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
|
||||||
|
|
||||||
PASSWORD_ENV = {
|
PASSWORD_ENV = {
|
||||||
@@ -17,6 +20,18 @@ def _configure_current_user(mock_redmine, user_id=1):
|
|||||||
mock_redmine.user.get.return_value = mock_user
|
mock_redmine.user.get.return_value = mock_user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_truststore():
|
||||||
|
"""Мок truststore для всех тестов клиента.
|
||||||
|
|
||||||
|
При verify=True _create_redmine() вызывает truststore.inject_into_ssl(),
|
||||||
|
который глобально подменяет ssl.SSLContext без восстановления (#62).
|
||||||
|
Не даём реальной инъекции выполниться в pytest-процессе.
|
||||||
|
"""
|
||||||
|
with mock.patch("redmine_reporter.client.truststore") as m:
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.client.Redmine")
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
|
def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
|
||||||
@@ -43,7 +58,7 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
issue, total_hours = result[0]
|
_issue, total_hours, _activities = result[0]
|
||||||
assert total_hours == 3.5
|
assert total_hours == 3.5
|
||||||
|
|
||||||
|
|
||||||
@@ -107,7 +122,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
|
||||||
|
|
||||||
@@ -155,7 +170,9 @@ def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
|
|||||||
assert "key" not in kwargs
|
assert "key" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True)
|
@mock.patch.dict(
|
||||||
|
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
|
||||||
|
)
|
||||||
@mock.patch("redmine_reporter.client.Redmine")
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
def test_fetch_uses_custom_verify_path(mock_redmine_class):
|
def test_fetch_uses_custom_verify_path(mock_redmine_class):
|
||||||
mock_redmine = mock_redmine_class.return_value
|
mock_redmine = mock_redmine_class.return_value
|
||||||
@@ -168,6 +185,341 @@ def test_fetch_uses_custom_verify_path(mock_redmine_class):
|
|||||||
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
|
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "true"}, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_verify_true_injects_system_trust_store(mock_redmine_class, mock_truststore):
|
||||||
|
"""verify=True → truststore.inject_into_ssl() вызван, verify=True в Redmine (#62)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
mock_truststore.inject_into_ssl.assert_called_once_with()
|
||||||
|
_, kwargs = mock_redmine_class.call_args
|
||||||
|
assert kwargs["requests"]["verify"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "false"}, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_verify_false_skips_truststore_injection(mock_redmine_class, mock_truststore):
|
||||||
|
"""verify=False → truststore.inject_into_ssl() не вызывается."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
mock_truststore.inject_into_ssl.assert_not_called()
|
||||||
|
_, kwargs = mock_redmine_class.call_args
|
||||||
|
assert kwargs["requests"]["verify"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(
|
||||||
|
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
|
||||||
|
)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_verify_custom_path_skips_truststore_injection(
|
||||||
|
mock_redmine_class, mock_truststore
|
||||||
|
):
|
||||||
|
"""verify=<путь к CA-bundle> → truststore.inject_into_ssl() не вызывается."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
mock_truststore.inject_into_ssl.assert_not_called()
|
||||||
|
_, kwargs = mock_redmine_class.call_args
|
||||||
|
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_verify_default_injects_system_trust_store(mock_redmine_class, mock_truststore):
|
||||||
|
"""Без REDMINE_VERIFY verify по умолчанию True → inject вызван (#62)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
mock_truststore.inject_into_ssl.assert_called_once_with()
|
||||||
|
_, kwargs = mock_redmine_class.call_args
|
||||||
|
assert kwargs["requests"]["verify"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
|
||||||
|
"""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_user.login = "ivanov"
|
||||||
|
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_rejects_inexact_login_match(mock_redmine_class):
|
||||||
|
"""Фильтр Redmine по логину неточный (#60): без точного совпадения — ошибка."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
near_match = mock.MagicMock()
|
||||||
|
near_match.id = 8
|
||||||
|
near_match.login = "ivanov2"
|
||||||
|
mock_redmine.user.filter.side_effect = [
|
||||||
|
[near_match], # неточное совпадение по логину
|
||||||
|
[], # по имени никого не найдено
|
||||||
|
]
|
||||||
|
|
||||||
|
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="ivanov")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_resolves_exact_login_among_near_matches(mock_redmine_class):
|
||||||
|
"""Среди неточных результатов фильтра выбирается точное совпадение логина (#60)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
near = mock.MagicMock()
|
||||||
|
near.id = 8
|
||||||
|
near.login = "ivanov2"
|
||||||
|
exact = mock.MagicMock()
|
||||||
|
exact.id = 7
|
||||||
|
exact.login = "ivanov"
|
||||||
|
mock_redmine.user.filter.return_value = [near, exact]
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="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_rejects_multiple_exact_login_matches(mock_redmine_class):
|
||||||
|
"""Несколько пользователей с точным логином — ошибка неоднозначности (#60)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
user1 = mock.MagicMock()
|
||||||
|
user1.id = 7
|
||||||
|
user1.login = "ivanov"
|
||||||
|
user2 = mock.MagicMock()
|
||||||
|
user2.id = 9
|
||||||
|
user2.login = "ivanov"
|
||||||
|
mock_redmine.user.filter.return_value = [user1, user2]
|
||||||
|
|
||||||
|
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="ivanov")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_login_exact_match_is_case_sensitive(mock_redmine_class):
|
||||||
|
"""Точное совпадение логина регистрозависимое (#60)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
other_case = mock.MagicMock()
|
||||||
|
other_case.id = 8
|
||||||
|
other_case.login = "Ivanov"
|
||||||
|
mock_redmine.user.filter.side_effect = [
|
||||||
|
[other_case], # логин отличается регистром
|
||||||
|
[], # по имени никого не найдено
|
||||||
|
]
|
||||||
|
|
||||||
|
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="ivanov")
|
||||||
|
|
||||||
|
|
||||||
|
@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 --
|
# -- #24: Таймаут и retry --
|
||||||
|
|
||||||
|
|
||||||
@@ -203,7 +555,9 @@ def test_fetch_mounts_retry_adapter(mock_redmine_class):
|
|||||||
assert "http://" in prefixes
|
assert "http://" in prefixes
|
||||||
|
|
||||||
# Проверяем retry-конфигурацию адаптера
|
# Проверяем retry-конфигурацию адаптера
|
||||||
https_adapter = next(call.args[1] for call in mount_calls if call.args[0] == "https://")
|
https_adapter = next(
|
||||||
|
call.args[1] for call in mount_calls if call.args[0] == "https://"
|
||||||
|
)
|
||||||
max_retries = https_adapter.max_retries
|
max_retries = https_adapter.max_retries
|
||||||
assert max_retries.total == 3
|
assert max_retries.total == 3
|
||||||
assert 429 in max_retries.status_forcelist
|
assert 429 in max_retries.status_forcelist
|
||||||
@@ -235,7 +589,9 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
|
|||||||
ids_str = kwargs.get("issue_id", "")
|
ids_str = kwargs.get("issue_id", "")
|
||||||
call_chunks.append(ids_str)
|
call_chunks.append(ids_str)
|
||||||
ids = [int(x) for x in ids_str.split(",")]
|
ids = [int(x) for x in ids_str.split(",")]
|
||||||
return [mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids]
|
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
|
mock_redmine.issue.filter.side_effect = issue_filter_side_effect
|
||||||
|
|
||||||
@@ -248,3 +604,402 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
|
|||||||
assert len(call_chunks[2].split(",")) == 50
|
assert len(call_chunks[2].split(",")) == 50
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 250
|
assert len(result) == 250
|
||||||
|
|
||||||
|
|
||||||
|
# -- Пагинация time entries (>100 записей) --
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
|
||||||
|
"""Time entries приходят страницами по 100 — часы агрегируются по всем страницам."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
|
||||||
|
def make_entry(issue_id, hours):
|
||||||
|
e = mock.MagicMock()
|
||||||
|
e.issue.id = issue_id
|
||||||
|
e.hours = hours
|
||||||
|
return e
|
||||||
|
|
||||||
|
# Эмулируем ленивую пагинацию redminelib ResourceSet: записи отдаются
|
||||||
|
# генератором порциями ("страницами" по 100), как при догрузке с сервера.
|
||||||
|
# Если клиент прочитает только первую страницу, агрегация будет неверной.
|
||||||
|
pages = [
|
||||||
|
[make_entry(1, 0.5) for _ in range(100)],
|
||||||
|
[make_entry(2, 1.0) for _ in range(100)],
|
||||||
|
[make_entry(1, 0.5) for _ in range(50)],
|
||||||
|
]
|
||||||
|
|
||||||
|
def paged_entries():
|
||||||
|
for page in pages:
|
||||||
|
yield from page
|
||||||
|
|
||||||
|
mock_redmine.time_entry.filter.return_value = paged_entries()
|
||||||
|
|
||||||
|
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-01-01", "2026-03-31")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
hours_by_id = {issue.id: hours for issue, hours, _ in result}
|
||||||
|
# issue 1: 100×0.5 (стр. 1) + 50×0.5 (стр. 3) = 75.0
|
||||||
|
assert hours_by_id[1] == 75.0
|
||||||
|
# issue 2: 100×1.0 (стр. 2) = 100.0
|
||||||
|
assert hours_by_id[2] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
# -- #61: предупреждения о молчаливой потере данных --
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_warns_about_missing_issues(mock_redmine_class, capsys):
|
||||||
|
"""Задачи, не вернувшиеся из issue.filter, попадают в warning в stderr (#61).
|
||||||
|
|
||||||
|
Отчёт при этом строится по доступным данным — это предупреждение, не ошибка.
|
||||||
|
"""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
|
||||||
|
def make_entry(issue_id, hours):
|
||||||
|
e = mock.MagicMock()
|
||||||
|
e.issue.id = issue_id
|
||||||
|
e.hours = hours
|
||||||
|
return e
|
||||||
|
|
||||||
|
mock_redmine.time_entry.filter.return_value = [
|
||||||
|
make_entry(1, 1.0),
|
||||||
|
make_entry(2, 2.5),
|
||||||
|
make_entry(3, 0.75),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_issue1 = mock.MagicMock()
|
||||||
|
mock_issue1.id = 1
|
||||||
|
mock_issue1.project = "P"
|
||||||
|
mock_issue1.subject = "T"
|
||||||
|
mock_issue1.status = "New"
|
||||||
|
# Задачи 2 и 3 не вернулись (нет прав / удалены)
|
||||||
|
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert [r[0].id for r in result] == [1]
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "2" in captured.err
|
||||||
|
assert "3" in captured.err
|
||||||
|
assert "3.25" in captured.err # сумма потерянных часов 2.5 + 0.75
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_no_warning_when_all_issues_returned(mock_redmine_class, capsys):
|
||||||
|
"""Если все задачи вернулись, stderr остаётся чистым (#61)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
|
||||||
|
e = mock.MagicMock()
|
||||||
|
e.issue.id = 1
|
||||||
|
e.hours = 1.0
|
||||||
|
mock_redmine.time_entry.filter.return_value = [e]
|
||||||
|
|
||||||
|
mock_issue1 = mock.MagicMock()
|
||||||
|
mock_issue1.id = 1
|
||||||
|
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert captured.err == ""
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_warns_when_activities_lookup_fails(mock_redmine_class, capsys):
|
||||||
|
"""Сбой справочника активностей — warning в stderr, отчёт строится (#61)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
mock_redmine.enumeration.filter.side_effect = Exception("enumeration boom")
|
||||||
|
|
||||||
|
e = mock.MagicMock()
|
||||||
|
e.issue.id = 1
|
||||||
|
e.hours = 1.0
|
||||||
|
e.activity.id = 9
|
||||||
|
e.activity.name = "Dev"
|
||||||
|
mock_redmine.time_entry.filter.return_value = [e]
|
||||||
|
|
||||||
|
mock_issue1 = mock.MagicMock()
|
||||||
|
mock_issue1.id = 1
|
||||||
|
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31", by_activity=True)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result[0][2] == {"Dev": 1.0} # fallback на имя из самого entry
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "activit" in captured.err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_time_entry_activities_returns_empty_on_failure(capsys):
|
||||||
|
"""_load_time_entry_activities возвращает {} и пишет warning при сбое (#61)."""
|
||||||
|
from redmine_reporter.client import _load_time_entry_activities
|
||||||
|
|
||||||
|
redmine = mock.MagicMock()
|
||||||
|
redmine.enumeration.filter.side_effect = Exception("enumeration boom")
|
||||||
|
|
||||||
|
assert _load_time_entry_activities(redmine) == {}
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "activit" in captured.err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Границы периода передаются в API как есть --
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_passes_date_boundaries_to_api_unchanged(mock_redmine_class):
|
||||||
|
"""Флоринг границ выполняет cli-парсер, клиент отдаёт даты в filter как есть."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_issues_with_spent_time("2026-09-30", "2026-10-31", user_id=458)
|
||||||
|
|
||||||
|
mock_redmine.time_entry.filter.assert_called_once_with(
|
||||||
|
user_id=458, from_date="2026-09-30", to_date="2026-10-31"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_works_without_dedup_argument(mock_redmine_class):
|
||||||
|
"""dedup-слой удалён: без dedup-аргумента все записи периода агрегируются."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
|
||||||
|
entry = mock.MagicMock()
|
||||||
|
entry.issue.id = 1
|
||||||
|
entry.hours = 2.0
|
||||||
|
entry.created_on = None
|
||||||
|
entry.updated_on = None
|
||||||
|
mock_redmine.time_entry.filter.return_value = [entry]
|
||||||
|
|
||||||
|
mock_issue = mock.MagicMock()
|
||||||
|
mock_issue.id = 1
|
||||||
|
mock_issue.project = "P"
|
||||||
|
mock_issue.subject = "T"
|
||||||
|
mock_issue.status = "New"
|
||||||
|
mock_redmine.issue.filter.return_value = [mock_issue]
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time("2026-09-30", "2026-09-30", user_id=123)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert [r[0].id for r in result] == [1]
|
||||||
|
assert result[0][1] == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
# -- #71: fetch_open_issues --
|
||||||
|
|
||||||
|
|
||||||
|
def _make_open_issue(issue_id, version_id=None):
|
||||||
|
"""Создаёт mock открытой задачи; без version_id эмулирует отсутствие fixed_version.
|
||||||
|
|
||||||
|
Спека без fixed_version нужна, чтобы getattr(issue, "fixed_version", None)
|
||||||
|
вернул None, а не auto-атрибут MagicMock.
|
||||||
|
"""
|
||||||
|
if version_id is None:
|
||||||
|
issue = mock.MagicMock(spec=["id", "subject", "project"])
|
||||||
|
issue.id = issue_id
|
||||||
|
return issue
|
||||||
|
issue = mock.MagicMock()
|
||||||
|
issue.id = issue_id
|
||||||
|
issue.fixed_version.id = version_id
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_open_issues_filters_by_user_and_open_status(mock_redmine_class):
|
||||||
|
"""issue.filter вызван ровно с assigned_to_id и status_id="open" (#71)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
mock_redmine.issue.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_open_issues()
|
||||||
|
|
||||||
|
mock_redmine.issue.filter.assert_called_once_with(
|
||||||
|
assigned_to_id=123, status_id="open"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_open_issues_default_user_is_current(mock_redmine_class):
|
||||||
|
"""Без user_id резолвится текущий пользователь через user.get("current") (#71)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=77)
|
||||||
|
mock_redmine.issue.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_open_issues()
|
||||||
|
|
||||||
|
mock_redmine.user.get.assert_called_once_with("current")
|
||||||
|
_, kwargs = mock_redmine.issue.filter.call_args
|
||||||
|
assert kwargs["assigned_to_id"] == 77
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_open_issues_numeric_user_id_skips_user_lookup(mock_redmine_class):
|
||||||
|
"""Числовой user_id: user.get не вызывается, фильтр по переданному ID (#71)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
mock_redmine.issue.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_open_issues(user_id=42)
|
||||||
|
|
||||||
|
mock_redmine.user.get.assert_not_called()
|
||||||
|
mock_redmine.issue.filter.assert_called_once_with(
|
||||||
|
assigned_to_id=42, status_id="open"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_open_issues_resolves_login_to_id(mock_redmine_class):
|
||||||
|
"""Строковый логин разрешается в числовой ID как в fetch_issues_with_spent_time (#71)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine)
|
||||||
|
|
||||||
|
mock_user = mock.MagicMock()
|
||||||
|
mock_user.id = 7
|
||||||
|
mock_user.login = "ivanov"
|
||||||
|
mock_redmine.user.filter.return_value = [mock_user]
|
||||||
|
mock_redmine.issue.filter.return_value = []
|
||||||
|
|
||||||
|
fetch_open_issues(user_id="ivanov")
|
||||||
|
|
||||||
|
login_filter_call = mock_redmine.user.filter.call_args_list[0]
|
||||||
|
assert login_filter_call.kwargs == {"login": "ivanov"}
|
||||||
|
mock_redmine.issue.filter.assert_called_once_with(
|
||||||
|
assigned_to_id=7, status_id="open"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_open_issues_fetches_each_unique_version_once(mock_redmine_class):
|
||||||
|
"""Две задачи с одной версией -> один version.get на версию (#71)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
mock_redmine.issue.filter.return_value = [
|
||||||
|
_make_open_issue(1, version_id=5),
|
||||||
|
_make_open_issue(2, version_id=5),
|
||||||
|
_make_open_issue(3, version_id=6),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = fetch_open_issues()
|
||||||
|
|
||||||
|
assert mock_redmine.version.get.call_count == 2
|
||||||
|
version_ids = [call.args[0] for call in mock_redmine.version.get.call_args_list]
|
||||||
|
assert version_ids == [5, 6]
|
||||||
|
assert len(result) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_open_issues_attaches_version_due_date(mock_redmine_class):
|
||||||
|
"""due_date из version.get попадает в кортеж задачи (#71)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
|
||||||
|
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
|
||||||
|
|
||||||
|
result = fetch_open_issues()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
issue, due = result[0]
|
||||||
|
assert issue.id == 1
|
||||||
|
assert due == date(2026, 11, 1)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_open_issues_issue_without_version_has_empty_due(mock_redmine_class):
|
||||||
|
"""Задача без fixed_version: version.get для неё не вызывается, due None (#71)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
mock_redmine.issue.filter.return_value = [
|
||||||
|
_make_open_issue(1, version_id=5),
|
||||||
|
_make_open_issue(2),
|
||||||
|
]
|
||||||
|
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
|
||||||
|
|
||||||
|
result = fetch_open_issues()
|
||||||
|
|
||||||
|
mock_redmine.version.get.assert_called_once_with(5)
|
||||||
|
assert result is not None
|
||||||
|
assert result[1][0].id == 2
|
||||||
|
assert result[1][1] is None
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_open_issues_version_failure_warns_and_continues(mock_redmine_class, capsys):
|
||||||
|
"""Сбой version.get: warning в stderr, due None, общий результат не валится (#71)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
|
||||||
|
mock_redmine.version.get.side_effect = Exception("version boom")
|
||||||
|
|
||||||
|
result = fetch_open_issues()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result[0][1] is None
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Could not load version 5" in captured.err
|
||||||
|
assert "version boom" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_open_issues_result_length_matches_issues(mock_redmine_class):
|
||||||
|
"""Результат по длине и порядку соответствует списку задач из API (#71)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
mock_redmine.issue.filter.return_value = [
|
||||||
|
_make_open_issue(1, version_id=5),
|
||||||
|
_make_open_issue(2),
|
||||||
|
_make_open_issue(3, version_id=6),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = fetch_open_issues()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) == 3
|
||||||
|
assert [issue.id for issue, _ in result] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_open_issues_wraps_api_errors(mock_redmine_class):
|
||||||
|
"""Сбой issue.filter оборачивается в RedmineAPIError с понятным сообщением (#71)."""
|
||||||
|
from redminelib.exceptions import AuthError
|
||||||
|
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
mock_redmine.issue.filter.side_effect = AuthError()
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="Authentication failed"):
|
||||||
|
fetch_open_issues()
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import ClassVar
|
||||||
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 AppConfig, Config, EmailConfig
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def mock_today(fake_today):
|
||||||
|
"""Подменяет date.today() в config, сохраняя реальные date() и fromisoformat()."""
|
||||||
|
from datetime import date as real_date
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||||
|
mock_date.today.return_value = fake_today
|
||||||
|
mock_date.side_effect = lambda *a, **kw: real_date(*a, **kw)
|
||||||
|
mock_date.fromisoformat.side_effect = real_date.fromisoformat
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(
|
@mock.patch.dict(
|
||||||
@@ -60,38 +76,150 @@ def test_get_author_fallback():
|
|||||||
assert Config.get_author("") == ""
|
assert Config.get_author("") == ""
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(
|
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-01-01"}, clear=True)
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"DEFAULT_FROM_DATE": "2026-01-01",
|
|
||||||
"DEFAULT_TO_DATE": "2026-01-31",
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
)
|
|
||||||
def test_get_default_date_range_from_env():
|
def test_get_default_date_range_from_env():
|
||||||
assert Config.get_default_date_range() == "2026-01-01--2026-01-31"
|
"""FROM_DATE задаёт начало периода, конец всегда -- сегодня."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
today = date.today().isoformat()
|
||||||
|
assert Config.get_default_date_range() == f"2026-01-01--{today}"
|
||||||
|
|
||||||
|
|
||||||
@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
|
from datetime import date
|
||||||
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
start = today.replace(day=1)
|
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 result == f"{start.isoformat()}--{end.isoformat()}"
|
assert result == f"{start.isoformat()}--{today.isoformat()}"
|
||||||
|
|
||||||
|
|
||||||
|
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) --
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultPeriodIsCurrentMonth:
|
||||||
|
"""Без --date, env и YAML период = с 1-го числа текущего месяца по сегодня."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_mid_month(self):
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||||
|
mock_date.today.return_value = date(2026, 3, 14)
|
||||||
|
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||||||
|
Config._app = None
|
||||||
|
assert Config.get_default_date_range() == "2026-03-01--2026-03-14"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_first_day_of_month(self):
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||||
|
mock_date.today.return_value = date(2026, 5, 1)
|
||||||
|
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||||||
|
Config._app = None
|
||||||
|
assert Config.get_default_date_range() == "2026-05-01--2026-05-01"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_yaml_period_from_runs_to_today(self):
|
||||||
|
"""period.from задан -- конец периода всегда сегодня."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("period:\n from: '2026-04-01'\n")
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
with mock_today(date(2026, 4, 20)):
|
||||||
|
assert Config.get_default_date_range() == "2026-04-01--2026-04-20"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultDateRangePeriodFrom:
|
||||||
|
"""Приоритет окна: env FROM_DATE → YAML period.from → текущий месяц."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-02-01"}, clear=True)
|
||||||
|
def test_env_from_runs_to_today(self):
|
||||||
|
"""FROM_DATE задаёт начало, конец всегда сегодня."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
Config._app = None
|
||||||
|
with mock_today(date(2026, 2, 10)):
|
||||||
|
assert Config.get_default_date_range() == "2026-02-01--2026-02-10"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True)
|
||||||
|
def test_env_from_wins_over_yaml_period_from(self):
|
||||||
|
"""env FROM_DATE имеет приоритет над YAML period.from."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("period:\n from: '2026-03-01'\n")
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
with mock_today(date(2026, 7, 15)):
|
||||||
|
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-12-31"}, clear=True)
|
||||||
|
def test_env_from_in_future_clamps_to_today(self):
|
||||||
|
"""from позже сегодня -- окно 'сегодня--сегодня', без инверсии."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
Config._app = None
|
||||||
|
with mock_today(date(2026, 9, 15)):
|
||||||
|
assert Config.get_default_date_range() == "2026-09-15--2026-09-15"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_yaml_period_from_in_future_clamps_to_today(self):
|
||||||
|
"""YAML period.from позже сегодня -- окно 'сегодня--сегодня'."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("period:\n from: '2026-12-31'\n")
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
with mock_today(date(2026, 9, 15)):
|
||||||
|
assert Config.get_default_date_range() == "2026-09-15--2026-09-15"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_malformed_yaml_period_from_warns_and_uses_current_month(self, caplog):
|
||||||
|
"""period.from не дата -- warning в лог, окно = текущий месяц."""
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("period:\n from: 'not-a-date'\n")
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING), mock_today(date(2026, 9, 15)):
|
||||||
|
rng = Config.get_default_date_range()
|
||||||
|
|
||||||
|
assert rng == "2026-09-01--2026-09-15"
|
||||||
|
assert "not-a-date" in caplog.text
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"FROM_DATE": "garbage"}, clear=True)
|
||||||
|
def test_malformed_env_from_warns_and_uses_current_month(self, caplog):
|
||||||
|
"""FROM_DATE не дата -- warning в лог, окно = текущий месяц."""
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
Config._app = None
|
||||||
|
with caplog.at_level(logging.WARNING), mock_today(date(2026, 9, 15)):
|
||||||
|
rng = Config.get_default_date_range()
|
||||||
|
|
||||||
|
assert rng == "2026-09-01--2026-09-15"
|
||||||
|
assert "garbage" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, {}, clear=True)
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
def test_get_redmine_verify_default():
|
def test_get_redmine_verify_default():
|
||||||
assert Config.get_redmine_verify() == DEFAULT_REDMINE_VERIFY
|
"""Без явной настройки проверка TLS включена (стандартные CA requests)."""
|
||||||
|
Config._app = None
|
||||||
|
assert Config.get_redmine_verify() is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"])
|
@pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"])
|
||||||
@@ -111,17 +239,106 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
|
# -- #54: REDMINE_URL обязан использовать HTTPS --
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigRequiresHttpsUrl:
|
||||||
|
"""Config.validate() отклоняет URL без TLS: API-ключ идёт в заголовках."""
|
||||||
|
|
||||||
|
@mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"REDMINE_URL": "http://red.eltex.loc/", "REDMINE_API_KEY": "token"},
|
||||||
|
clear=True,
|
||||||
|
)
|
||||||
|
def test_http_url_rejected(self):
|
||||||
|
with pytest.raises(ValueError, match="HTTPS"):
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
@mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"REDMINE_URL": "red.eltex.loc", "REDMINE_API_KEY": "token"},
|
||||||
|
clear=True,
|
||||||
|
)
|
||||||
|
def test_url_without_scheme_rejected(self):
|
||||||
|
with pytest.raises(ValueError, match="HTTPS"):
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
@mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"REDMINE_URL": "ftp://red.eltex.loc/", "REDMINE_API_KEY": "token"},
|
||||||
|
clear=True,
|
||||||
|
)
|
||||||
|
def test_non_http_scheme_rejected(self):
|
||||||
|
with pytest.raises(ValueError, match="HTTPS"):
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
@mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"},
|
||||||
|
clear=True,
|
||||||
|
)
|
||||||
|
def test_https_url_accepted(self):
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
@mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"REDMINE_URL": "HTTPS://red.eltex.loc/", "REDMINE_API_KEY": "token"},
|
||||||
|
clear=True,
|
||||||
|
)
|
||||||
|
def test_uppercase_scheme_accepted(self):
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
|
||||||
|
# -- #57: предупреждение при отключённой проверке TLS --
|
||||||
|
|
||||||
|
|
||||||
|
class TestWarnWhenTlsVerificationDisabled:
|
||||||
|
"""verify_ssl: false должен давать видимый warning в stderr (риск MITM)."""
|
||||||
|
|
||||||
|
_ENV: ClassVar[dict[str, str]] = {
|
||||||
|
"REDMINE_URL": "https://red.eltex.loc/",
|
||||||
|
"REDMINE_API_KEY": "token",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _validate_env(self, verify: str):
|
||||||
|
return {**self._ENV, "REDMINE_VERIFY": verify}
|
||||||
|
|
||||||
|
def test_verify_false_warns_in_stderr(self, capsys):
|
||||||
|
with mock.patch.dict(os.environ, self._validate_env("false"), clear=True):
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "⚠️" in err
|
||||||
|
assert "TLS" in err or "SSL" in err
|
||||||
|
|
||||||
|
def test_verify_true_no_warning(self, capsys):
|
||||||
|
with mock.patch.dict(os.environ, self._validate_env("true"), clear=True):
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
assert capsys.readouterr().err == ""
|
||||||
|
|
||||||
|
def test_verify_ca_path_no_warning(self, capsys):
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ, self._validate_env("/tmp/redmine-ca.pem"), clear=True
|
||||||
|
):
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
assert capsys.readouterr().err == ""
|
||||||
|
|
||||||
|
def test_verify_default_no_warning(self, capsys):
|
||||||
|
with mock.patch.dict(os.environ, self._ENV, clear=True):
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
assert capsys.readouterr().err == ""
|
||||||
|
|
||||||
|
|
||||||
# -- #15: .env не должен переопределять переменные окружения --
|
# -- #15: .env не должен переопределять переменные окружения --
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("dotenv.load_dotenv")
|
@mock.patch("redmine_reporter.config.load_dotenv")
|
||||||
def test_env_var_takes_priority_over_dotenv(mock_load):
|
def test_env_var_takes_priority_over_dotenv(mock_load):
|
||||||
"""load_dotenv вызывается с override=False — env vars не перебиваются .env."""
|
"""load_dotenv вызывается с override=False — env vars не перебиваются .env."""
|
||||||
import importlib
|
Config.load_yaml("/nonexistent/config.yml")
|
||||||
|
|
||||||
from redmine_reporter import config as cfg_mod
|
|
||||||
|
|
||||||
importlib.reload(cfg_mod)
|
|
||||||
|
|
||||||
mock_load.assert_called_once_with(override=False)
|
mock_load.assert_called_once_with(override=False)
|
||||||
|
|
||||||
@@ -133,3 +350,625 @@ def test_env_var_takes_priority_over_dotenv(mock_load):
|
|||||||
def test_get_redmine_password_strips_whitespace():
|
def test_get_redmine_password_strips_whitespace():
|
||||||
"""Пароль обрезается от whitespace, как и все остальные геттеры."""
|
"""Пароль обрезается от whitespace, как и все остальные геттеры."""
|
||||||
assert Config.get_redmine_password() == "secret123"
|
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:
|
||||||
|
from: "2026-06-01"
|
||||||
|
|
||||||
|
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_from == "2026-06-01"
|
||||||
|
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_from == ""
|
||||||
|
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_from == ""
|
||||||
|
|
||||||
|
def test_period_from_loaded_from_yaml(self):
|
||||||
|
"""period.from читается из YAML как единственный ключ периода."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("period:\n from: '2026-06-01'\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.period_from == "2026-06-01"
|
||||||
|
|
||||||
|
def test_old_period_keys_are_ignored(self):
|
||||||
|
"""Устаревшие ключи period молча игнорируются -- читается только from."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"period:\n"
|
||||||
|
" precision: datetime\n"
|
||||||
|
" dynamic: true\n"
|
||||||
|
" default_from: '2026-01-01'\n"
|
||||||
|
" default_to: '2026-01-31'\n"
|
||||||
|
" last_used:\n"
|
||||||
|
" from: '2026-01-31T09:00:00'\n"
|
||||||
|
" to: '2026-01-31T12:00:00'\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.period_from == ""
|
||||||
|
|
||||||
|
def test_file_not_found_returns_all_defaults(self):
|
||||||
|
cfg = AppConfig.from_yaml("/nonexistent/path/config.yml")
|
||||||
|
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_true(self):
|
||||||
|
"""verify_ssl: true → bool True (стандартная проверка TLS)."""
|
||||||
|
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 is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["true", "True", "yes", "on", "1"])
|
||||||
|
def test_verify_ssl_true_string_returns_true(self, value):
|
||||||
|
"""Строковые true-значения в YAML → bool True, как и из env."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.redmine_verify is True
|
||||||
|
|
||||||
|
def test_verify_ssl_custom_path_returns_path(self):
|
||||||
|
"""Явный путь к CA-bundle в YAML передаётся как есть."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("redmine:\n verify_ssl: /etc/ssl/my-ca.pem\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.redmine_verify == "/etc/ssl/my-ca.pem"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["false", "False", "no", "off", "0"])
|
||||||
|
def test_verify_ssl_false_string_returns_false(self, value):
|
||||||
|
"""Строковые false-значения в YAML → bool False."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.redmine_verify is False
|
||||||
|
|
||||||
|
def test_verify_ssl_missing_returns_true(self):
|
||||||
|
"""Без verify_ssl в YAML — проверка TLS включена (True)."""
|
||||||
|
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_verify is 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):
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("period:\n from: '2026-03-01'\n")
|
||||||
|
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
with mock_today(date(2026, 3, 15)):
|
||||||
|
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):
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("period:\n from: '2026-03-01'\n")
|
||||||
|
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True),
|
||||||
|
mock_today(date(2026, 7, 15)),
|
||||||
|
):
|
||||||
|
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetEmailConfig:
|
||||||
|
"""Tests for Config.get_email_config()."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_get_email_config_from_yaml(self):
|
||||||
|
"""get_email_config() возвращает EmailConfig из YAML."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"email:\n"
|
||||||
|
" smtp:\n"
|
||||||
|
" host: smtp.example.com\n"
|
||||||
|
" port: 587\n"
|
||||||
|
" user: bot@example.com\n"
|
||||||
|
" password: secret\n"
|
||||||
|
" tls: true\n"
|
||||||
|
" from: bot@example.com\n"
|
||||||
|
" to:\n"
|
||||||
|
" - boss@example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
cfg = Config.get_email_config()
|
||||||
|
|
||||||
|
assert cfg is not None
|
||||||
|
assert cfg.smtp.host == "smtp.example.com"
|
||||||
|
assert cfg.smtp.port == 587
|
||||||
|
assert cfg.smtp.user == "bot@example.com"
|
||||||
|
assert cfg.smtp.password == "secret"
|
||||||
|
assert cfg.smtp.tls is True
|
||||||
|
assert cfg.from_ == "bot@example.com"
|
||||||
|
assert cfg.to == ["boss@example.com"]
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_get_email_config_returns_none_when_no_yaml(self):
|
||||||
|
"""Без YAML-конфига get_email_config() возвращает None."""
|
||||||
|
Config._app = None
|
||||||
|
assert Config.get_email_config() is None
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_get_email_config_returns_none_when_no_host(self):
|
||||||
|
"""С YAML но без smtp.host — возвращает None."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("email:\n smtp:\n host: ''\n")
|
||||||
|
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert Config.get_email_config() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailConfigHtml:
|
||||||
|
"""Tests for EmailConfig.html field."""
|
||||||
|
|
||||||
|
def test_email_config_html_from_yaml(self):
|
||||||
|
"""email.html: true загружается из YAML."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"email:\n html: true\n smtp:\n host: smtp.example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert cfg.email.html is True
|
||||||
|
|
||||||
|
def test_email_config_html_defaults_to_false(self):
|
||||||
|
"""email.html по умолчанию False."""
|
||||||
|
assert EmailConfig().html is False
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("email:\n smtp:\n host: smtp.example.com\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert cfg.email.html is False
|
||||||
|
|
||||||
|
def test_email_config_html_false_from_yaml(self):
|
||||||
|
"""email.html: false явно загружается из YAML."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"email:\n html: false\n smtp:\n host: smtp.example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert cfg.email.html is False
|
||||||
|
|
||||||
|
def test_email_config_html_invalid_string_defaults_to_false(self):
|
||||||
|
"""email.html со строкой 'invalid' приводится к False."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"email:\n html: 'invalid'\n smtp:\n host: smtp.example.com\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert cfg.email.html is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportNoTime:
|
||||||
|
"""Tests for Config.get_report_no_time()."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_get_report_no_time_from_yaml(self):
|
||||||
|
"""report.no_time: true загружается из YAML."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("report:\n no_time: true\n")
|
||||||
|
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert Config.get_report_no_time() is True
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_get_report_no_time_defaults_to_false(self):
|
||||||
|
"""Без YAML-конфига report.no_time по умолчанию False."""
|
||||||
|
Config._app = None
|
||||||
|
assert Config.get_report_no_time() is False
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_get_report_no_time_from_yaml_false(self):
|
||||||
|
"""report.no_time: false явно загружается из YAML."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("report:\n no_time: false\n")
|
||||||
|
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert Config.get_report_no_time() is False
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_get_report_no_time_invalid_type_defaults_to_false(self):
|
||||||
|
"""report.no_time со строковым значением приводится к False."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("report:\n no_time: 'invalid'\n")
|
||||||
|
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
assert Config.get_report_no_time() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #65: report.status_translation — перевод статусов из YAML
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusTranslationFromYaml:
|
||||||
|
"""Tests for report.status_translation loading in AppConfig.from_yaml()."""
|
||||||
|
|
||||||
|
def test_status_translation_loaded_from_yaml(self):
|
||||||
|
"""report.status_translation заполняет поле AppConfig (override + новый)."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"report:\n"
|
||||||
|
" status_translation:\n"
|
||||||
|
' New: "Новая"\n'
|
||||||
|
' "Custom Status": "Кастом"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {
|
||||||
|
"New": "Новая",
|
||||||
|
"Custom Status": "Кастом",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_status_translation_missing_defaults_to_empty(self):
|
||||||
|
"""Без секции report.status_translation поле пустое."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("report:\n no_time: true\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {}
|
||||||
|
|
||||||
|
def test_status_translation_non_mapping_warns_and_ignored(self, caplog):
|
||||||
|
"""status_translation не-mapping (строка) → warning в лог, поле {}."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("report:\n status_translation: 'just a string'\n")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {}
|
||||||
|
assert "status_translation" in caplog.text
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"STATUS_RU": "На проверке"}, clear=True)
|
||||||
|
def test_status_translation_env_var_resolved(self):
|
||||||
|
"""${VAR} в значении перевода резолвится из окружения."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"report:\n status_translation:\n Testing: ${STATUS_RU}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {"Testing": "На проверке"}
|
||||||
|
|
||||||
|
def test_status_translation_null_value_skipped_with_warning(self, caplog):
|
||||||
|
"""Значение null пропускается с warning, а не превращается в 'None'."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"report:\n status_translation:\n New:\n Closed: Закрыто\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {"Closed": "Закрыто"}
|
||||||
|
assert "status_translation" in caplog.text
|
||||||
|
assert "New" in caplog.text
|
||||||
|
|
||||||
|
def test_status_translation_nested_dict_skipped_with_warning(self, caplog):
|
||||||
|
"""Вложенный dict в значении пропускается с warning, а не в repr."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"report:\n"
|
||||||
|
" status_translation:\n"
|
||||||
|
" New:\n"
|
||||||
|
" nested: value\n"
|
||||||
|
" Closed: Закрыто\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {"Closed": "Закрыто"}
|
||||||
|
assert "status_translation" in caplog.text
|
||||||
|
assert "New" in caplog.text
|
||||||
|
|
||||||
|
def test_status_translation_int_value_coerced_to_str(self):
|
||||||
|
"""Scalar-значения (int) по-прежнему приводятся к str."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("report:\n status_translation:\n New: 42\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {"New": "42"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetStatusTranslation:
|
||||||
|
"""Tests for Config.get_status_translation()."""
|
||||||
|
|
||||||
|
def test_no_yaml_returns_builtin(self):
|
||||||
|
"""Без YAML-конфига возвращается встроенный словарь."""
|
||||||
|
from redmine_reporter.report_builder import STATUS_TRANSLATION
|
||||||
|
|
||||||
|
Config._app = None
|
||||||
|
try:
|
||||||
|
assert Config.get_status_translation() == STATUS_TRANSLATION
|
||||||
|
finally:
|
||||||
|
Config._app = None
|
||||||
|
|
||||||
|
def test_empty_overrides_returns_builtin(self):
|
||||||
|
"""Пустые overrides — результат равен встроенному словарю."""
|
||||||
|
from redmine_reporter.report_builder import STATUS_TRANSLATION
|
||||||
|
|
||||||
|
Config._app = AppConfig()
|
||||||
|
try:
|
||||||
|
assert Config.get_status_translation() == STATUS_TRANSLATION
|
||||||
|
finally:
|
||||||
|
Config._app = None
|
||||||
|
|
||||||
|
def test_override_and_extend(self):
|
||||||
|
"""Override существующего статуса + добавление нового."""
|
||||||
|
Config._app = AppConfig(
|
||||||
|
report_status_translation={"New": "Новая", "Custom Status": "Кастом"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
translation = Config.get_status_translation()
|
||||||
|
finally:
|
||||||
|
Config._app = None
|
||||||
|
|
||||||
|
assert translation["New"] == "Новая"
|
||||||
|
assert translation["Custom Status"] == "Кастом"
|
||||||
|
# остальные встроенные переводы не тронуты
|
||||||
|
assert translation["Closed"] == "Закрыто"
|
||||||
|
|
||||||
|
def test_result_is_a_copy(self):
|
||||||
|
"""Мутация результата не трогает модульный STATUS_TRANSLATION."""
|
||||||
|
from redmine_reporter.report_builder import STATUS_TRANSLATION
|
||||||
|
|
||||||
|
Config._app = AppConfig(report_status_translation={"New": "Новая"})
|
||||||
|
try:
|
||||||
|
translation = Config.get_status_translation()
|
||||||
|
translation["New"] = "ИЗМЕНЕНО"
|
||||||
|
translation["Junk"] = "мусор"
|
||||||
|
finally:
|
||||||
|
Config._app = None
|
||||||
|
|
||||||
|
assert STATUS_TRANSLATION["New"] == "В работе"
|
||||||
|
assert "Junk" not in STATUS_TRANSLATION
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
from typing import List
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -25,7 +24,7 @@ def _make_empty_odt_bytes() -> bytes:
|
|||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def make_fake_report_rows() -> List[ReportRow]:
|
def make_fake_report_rows() -> list[ReportRow]:
|
||||||
"""
|
"""
|
||||||
Генерирует фейковый отчёт с полным покрытием логики группировки:
|
Генерирует фейковый отчёт с полным покрытием логики группировки:
|
||||||
- Проект A: версия v1.0 (2 задачи), версия v2.0 (1 задача)
|
- Проект A: версия v1.0 (2 задачи), версия v2.0 (1 задача)
|
||||||
@@ -42,6 +41,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",
|
||||||
@@ -52,6 +52,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",
|
||||||
@@ -62,6 +63,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",
|
||||||
@@ -72,6 +74,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",
|
||||||
@@ -82,6 +85,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",
|
||||||
@@ -92,6 +96,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",
|
||||||
@@ -102,6 +107,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,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -127,7 +133,9 @@ 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 как полноценного пакета --
|
# -- Тесты упаковки formatters как полноценного пакета --
|
||||||
@@ -164,7 +172,11 @@ def _simulate_missing_odfpy():
|
|||||||
|
|
||||||
saved = {}
|
saved = {}
|
||||||
for key in list(sys.modules.keys()):
|
for key in list(sys.modules.keys()):
|
||||||
if key == "odf" or key.startswith("odf.") or key == "redmine_reporter.formatters.odt":
|
if (
|
||||||
|
key == "odf"
|
||||||
|
or key.startswith("odf.")
|
||||||
|
or key == "redmine_reporter.formatters.odt"
|
||||||
|
):
|
||||||
saved[key] = sys.modules.pop(key)
|
saved[key] = sys.modules.pop(key)
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
@@ -342,7 +354,99 @@ def test_xlsx_save_creates_valid_file(fake_rows, tmp_path):
|
|||||||
assert ws["A1"].value == "Project"
|
assert ws["A1"].value == "Project"
|
||||||
assert ws["A2"].value == "Проект A"
|
assert ws["A2"].value == "Проект A"
|
||||||
assert ws["C2"].value == 101
|
assert ws["C2"].value == 101
|
||||||
assert ws.max_row == len(fake_rows) + 1 # header + data
|
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_has_full_header_row_and_grand_total_row(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
|
||||||
|
|
||||||
|
headers = [ws.cell(row=1, column=c).value for c in range(1, 8)]
|
||||||
|
assert headers == [
|
||||||
|
"Project",
|
||||||
|
"Version",
|
||||||
|
"Issue ID",
|
||||||
|
"Subject",
|
||||||
|
"Status",
|
||||||
|
"Hours",
|
||||||
|
"Spent Time",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Последняя строка — общий итог по всем проектам
|
||||||
|
assert ws.cell(row=ws.max_row, column=1).value == "Total"
|
||||||
|
assert ws.cell(row=ws.max_row, column=6).value == 22.5
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
@@ -377,6 +481,25 @@ def test_html_output_has_doctype_and_charset(fake_rows):
|
|||||||
assert '<meta charset="utf-8">' in output
|
assert '<meta charset="utf-8">' in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_has_table_structure_with_all_columns(fake_rows):
|
||||||
|
"""HTML-отчёт содержит thead со всеми колонками и по строке на каждую задачу."""
|
||||||
|
output = HTMLFormatter().format(fake_rows)
|
||||||
|
|
||||||
|
assert "<thead>" in output
|
||||||
|
assert "<tbody>" in output
|
||||||
|
for header in (
|
||||||
|
"Наименование Проекта",
|
||||||
|
"Номер версии*",
|
||||||
|
"Задача",
|
||||||
|
"Статус Готовность*",
|
||||||
|
"Затрачено за отчетный период",
|
||||||
|
):
|
||||||
|
assert f"<th>{header}</th>" in output
|
||||||
|
|
||||||
|
tbody = output.split("<tbody>", 1)[1]
|
||||||
|
assert tbody.count("<tr>") == len(fake_rows)
|
||||||
|
|
||||||
|
|
||||||
# -- Тесты ODT форматтера --
|
# -- Тесты ODT форматтера --
|
||||||
|
|
||||||
|
|
||||||
@@ -401,7 +524,9 @@ def test_odt_empty_author_no_garbage_in_header(fake_rows):
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
formatter = ODTFormatter(author="", from_date="2026-01-01", to_date="2026-01-31")
|
formatter = ODTFormatter(
|
||||||
|
author="", from_date="2026-01-01", to_date="2026-01-31"
|
||||||
|
)
|
||||||
doc = formatter.format(fake_rows)
|
doc = formatter.format(fake_rows)
|
||||||
|
|
||||||
from odf.text import P
|
from odf.text import P
|
||||||
@@ -428,7 +553,9 @@ def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
|
formatter = ODTFormatter(
|
||||||
|
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
|
||||||
|
)
|
||||||
output_file = tmp_path / "report.odt"
|
output_file = tmp_path / "report.odt"
|
||||||
formatter.save(fake_rows, str(output_file))
|
formatter.save(fake_rows, str(output_file))
|
||||||
|
|
||||||
@@ -464,7 +591,9 @@ def test_odt_has_covered_cells_for_spans(fake_rows):
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
|
formatter = ODTFormatter(
|
||||||
|
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
|
||||||
|
)
|
||||||
doc = formatter.format(fake_rows)
|
doc = formatter.format(fake_rows)
|
||||||
|
|
||||||
from odf.table import CoveredTableCell
|
from odf.table import CoveredTableCell
|
||||||
|
|||||||
50
tests/test_import_side_effects.py
Normal file
50
tests/test_import_side_effects.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""#64: импорт модулей не должен давать side effects в процессе."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _run_in_subprocess(code: str, cwd: Path) -> str:
|
||||||
|
"""Выполняет code в чистом интерпретаторе с доступом к пакету."""
|
||||||
|
env = {**os.environ, "PYTHONPATH": str(REPO_ROOT)}
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", code],
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_import_does_not_mutate_os_environ(tmp_path):
|
||||||
|
"""import redmine_reporter.config не подгружает .env из текущей директории."""
|
||||||
|
(tmp_path / ".env").write_text("RR_IMPORT_PROBE=1\n")
|
||||||
|
|
||||||
|
output = _run_in_subprocess(
|
||||||
|
"import os\n"
|
||||||
|
"import redmine_reporter.config\n"
|
||||||
|
"print('RR_IMPORT_PROBE' in os.environ)\n",
|
||||||
|
cwd=tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output == "False"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mailer_import_does_not_mutate_global_charset_registry(tmp_path):
|
||||||
|
"""import redmine_reporter.mailer не меняет email.charset для всего процесса."""
|
||||||
|
output = _run_in_subprocess(
|
||||||
|
"from email.charset import BASE64, Charset\n"
|
||||||
|
"before = Charset('utf-8').body_encoding\n"
|
||||||
|
"import redmine_reporter.mailer\n"
|
||||||
|
"after = Charset('utf-8').body_encoding\n"
|
||||||
|
"print(before == BASE64, after == BASE64)\n",
|
||||||
|
cwd=tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output == "True True"
|
||||||
337
tests/test_mailer.py
Normal file
337
tests/test_mailer.py
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import smtplib
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
from redmine_reporter.config import EmailConfig, SmtpConfig
|
||||||
|
from redmine_reporter.mailer import build_message, send_report
|
||||||
|
|
||||||
|
|
||||||
|
def _make_email_config(**overrides) -> EmailConfig:
|
||||||
|
"""Создаёт EmailConfig с минимальными валидными настройками."""
|
||||||
|
smtp = SmtpConfig(
|
||||||
|
host=overrides.pop("smtp_host", "smtp.example.com"),
|
||||||
|
port=overrides.pop("smtp_port", 587),
|
||||||
|
user=overrides.pop("smtp_user", "bot@example.com"),
|
||||||
|
password=overrides.pop("smtp_password", "secret"),
|
||||||
|
tls=overrides.pop("smtp_tls", True),
|
||||||
|
)
|
||||||
|
return EmailConfig(
|
||||||
|
smtp=smtp,
|
||||||
|
from_=overrides.pop("from_", "bot@example.com"),
|
||||||
|
to=overrides.pop("to", ["boss@example.com"]),
|
||||||
|
cc=overrides.pop("cc", []),
|
||||||
|
bcc=overrides.pop("bcc", []),
|
||||||
|
subject=overrides.pop("subject", "Отчёт {author} за {period}"),
|
||||||
|
body_text=overrides.pop("body_text", "Во вложении отчёт."),
|
||||||
|
attach=overrides.pop("attach", True),
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildMessage:
|
||||||
|
"""Тесты формирования MIME-письма."""
|
||||||
|
|
||||||
|
def test_subject_substitution(self):
|
||||||
|
"""{author} и {period} подставляются в тему."""
|
||||||
|
cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False)
|
||||||
|
msg = build_message(
|
||||||
|
cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30", []
|
||||||
|
)
|
||||||
|
assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30"
|
||||||
|
|
||||||
|
def test_body_substitution(self):
|
||||||
|
"""{author} и {period} подставляются в тело."""
|
||||||
|
cfg = _make_email_config(
|
||||||
|
body_text="Автор: {author}, период: {period}", attach=False
|
||||||
|
)
|
||||||
|
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", [])
|
||||||
|
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||||
|
assert len(plain_parts) == 1
|
||||||
|
payload = plain_parts[0].get_payload(decode=True).decode("utf-8")
|
||||||
|
assert "Автор: Иванов, период: Q1" in payload
|
||||||
|
|
||||||
|
def test_wire_format_is_8bit_utf8(self):
|
||||||
|
"""Сериализация в байты (как в smtplib.send_message) — 8bit UTF-8."""
|
||||||
|
from email.generator import BytesGenerator
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
cfg = _make_email_config(body_text="Автор: {author}", attach=False)
|
||||||
|
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
BytesGenerator(buf, policy=msg.policy.clone(linesep="\r\n")).flatten(msg)
|
||||||
|
data = buf.getvalue()
|
||||||
|
|
||||||
|
assert b"Content-Transfer-Encoding: 8bit" in data
|
||||||
|
assert "Автор: Иванов".encode() in data
|
||||||
|
|
||||||
|
def test_plain_part_uses_8bit_utf8(self):
|
||||||
|
"""Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть."""
|
||||||
|
cfg = _make_email_config(
|
||||||
|
body_text="Автор: {author}, период: {period}", attach=False
|
||||||
|
)
|
||||||
|
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
|
||||||
|
|
||||||
|
plain = next(p for p in msg.walk() if p.get_content_type() == "text/plain")
|
||||||
|
assert plain["Content-Transfer-Encoding"] == "8bit"
|
||||||
|
assert (
|
||||||
|
plain.get_payload(decode=True).decode("utf-8")
|
||||||
|
== "Автор: Иванов, период: Q1"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_from_header(self):
|
||||||
|
cfg = _make_email_config(from_="sender@example.com", attach=False)
|
||||||
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||||
|
assert msg["From"] == "sender@example.com"
|
||||||
|
|
||||||
|
def test_to_header(self):
|
||||||
|
cfg = _make_email_config(to=["a@x.com", "b@x.com"], attach=False)
|
||||||
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||||
|
assert msg["To"] == "a@x.com, b@x.com"
|
||||||
|
|
||||||
|
def test_cc_header(self):
|
||||||
|
cfg = _make_email_config(cc=["cc@x.com"], attach=False)
|
||||||
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||||
|
assert msg["Cc"] == "cc@x.com"
|
||||||
|
|
||||||
|
def test_bcc_not_in_headers(self):
|
||||||
|
"""BCC не должен появляться в заголовках письма."""
|
||||||
|
cfg = _make_email_config(bcc=["hidden@x.com"], attach=False)
|
||||||
|
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||||
|
assert "Bcc" not in msg
|
||||||
|
|
||||||
|
def test_attachment_present_when_attach_true(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake xlsx content")
|
||||||
|
cfg = _make_email_config(attach=True)
|
||||||
|
msg = build_message(cfg, str(report), "A", "P", [])
|
||||||
|
assert len(msg.get_payload()) == 2
|
||||||
|
|
||||||
|
def test_no_attachment_when_attach_false(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake xlsx content")
|
||||||
|
cfg = _make_email_config(attach=False)
|
||||||
|
msg = build_message(cfg, str(report), "A", "P", [])
|
||||||
|
assert len(msg.get_payload()) == 1
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"ext,expected_mime",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
".xlsx",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
),
|
||||||
|
(".odt", "application/vnd.oasis.opendocument.text"),
|
||||||
|
(".csv", "text/csv"),
|
||||||
|
(".html", "text/html"),
|
||||||
|
(".json", "application/json"),
|
||||||
|
(".md", "text/markdown"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_mime_type_by_extension(self, ext, expected_mime, tmp_path):
|
||||||
|
report = tmp_path / f"report{ext}"
|
||||||
|
report.write_text("content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
msg = build_message(cfg, str(report), "A", "P", [])
|
||||||
|
attachment_part = msg.get_payload()[1]
|
||||||
|
assert attachment_part.get_content_type() == expected_mime
|
||||||
|
|
||||||
|
def test_missing_attachment_file_raises_error(self, tmp_path):
|
||||||
|
"""Если файл вложения не существует — RedmineAPIError."""
|
||||||
|
cfg = _make_email_config()
|
||||||
|
missing = str(tmp_path / "nonexistent.xlsx")
|
||||||
|
with pytest.raises(RedmineAPIError, match="Не удалось прочитать файл"):
|
||||||
|
build_message(cfg, missing, "A", "P", [])
|
||||||
|
|
||||||
|
def test_html_part_present_when_html_true(self, tmp_path):
|
||||||
|
"""При email.html: true письмо содержит multipart/alternative с HTML."""
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(html=True)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"project": "Project",
|
||||||
|
"version": "1.0",
|
||||||
|
"issue_id": 1,
|
||||||
|
"subject": "Task",
|
||||||
|
"status_ru": "В работе",
|
||||||
|
"time_text": "2ч",
|
||||||
|
"hours": 2.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||||
|
|
||||||
|
alternatives = [
|
||||||
|
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
|
||||||
|
]
|
||||||
|
assert len(alternatives) == 1
|
||||||
|
|
||||||
|
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
|
||||||
|
assert len(html_parts) == 1
|
||||||
|
|
||||||
|
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||||
|
assert len(plain_parts) == 1
|
||||||
|
|
||||||
|
def test_no_html_part_when_html_false(self, tmp_path):
|
||||||
|
"""При email.html: false письмо содержит только plain-text."""
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(html=False)
|
||||||
|
msg = build_message(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
|
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
|
||||||
|
assert len(html_parts) == 0
|
||||||
|
|
||||||
|
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||||
|
assert len(plain_parts) == 1
|
||||||
|
|
||||||
|
def test_html_part_contains_report_data(self, tmp_path):
|
||||||
|
"""HTML-часть содержит данные отчёта."""
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(html=True)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"project": "Project X",
|
||||||
|
"version": "2.0",
|
||||||
|
"issue_id": 42,
|
||||||
|
"subject": "Important task",
|
||||||
|
"status_ru": "В работе",
|
||||||
|
"time_text": "3ч 30м",
|
||||||
|
"hours": 3.5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||||
|
|
||||||
|
html_part = next(p for p in msg.walk() if p.get_content_type() == "text/html")
|
||||||
|
payload = html_part.get_payload(decode=True).decode("utf-8")
|
||||||
|
assert "Project X" in payload
|
||||||
|
assert "Important task" in payload
|
||||||
|
|
||||||
|
def test_plain_text_before_html_in_alternative(self, tmp_path):
|
||||||
|
"""В multipart/alternative plain-text идёт перед HTML."""
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(html=True)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"project": "P",
|
||||||
|
"version": "V",
|
||||||
|
"issue_id": 1,
|
||||||
|
"subject": "S",
|
||||||
|
"status_ru": "В работе",
|
||||||
|
"time_text": "1ч",
|
||||||
|
"hours": 1.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||||
|
|
||||||
|
alternative = next(
|
||||||
|
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
|
||||||
|
)
|
||||||
|
payloads = alternative.get_payload()
|
||||||
|
assert payloads[0].get_content_type() == "text/plain"
|
||||||
|
assert payloads[1].get_content_type() == "text/html"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendReport:
|
||||||
|
"""Тесты отправки письма."""
|
||||||
|
|
||||||
|
def test_send_success(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
|
mock_smtp = mock.MagicMock()
|
||||||
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
|
||||||
|
send_report(cfg, str(report), "Кокос А.Н.", "2026-06-01--2026-06-30", [])
|
||||||
|
|
||||||
|
mock_smtp_class.assert_called_once_with("smtp.example.com", 587, timeout=30)
|
||||||
|
mock_smtp.starttls.assert_called_once()
|
||||||
|
mock_smtp.login.assert_called_once_with("bot@example.com", "secret")
|
||||||
|
mock_smtp.send_message.assert_called_once()
|
||||||
|
|
||||||
|
def test_send_no_tls(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(smtp_tls=False)
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
|
mock_smtp = mock.MagicMock()
|
||||||
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
|
||||||
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
|
mock_smtp.starttls.assert_not_called()
|
||||||
|
|
||||||
|
def test_send_connection_error(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")),
|
||||||
|
pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"),
|
||||||
|
):
|
||||||
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
|
def test_send_auth_error(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
|
mock_smtp = mock.MagicMock()
|
||||||
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
mock_smtp.login.side_effect = smtplib.SMTPAuthenticationError(
|
||||||
|
535, b"Bad auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"):
|
||||||
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
|
def test_send_timeout_error(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch("smtplib.SMTP", side_effect=TimeoutError()),
|
||||||
|
pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"),
|
||||||
|
):
|
||||||
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
|
def test_send_generic_smtp_error(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config()
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
|
mock_smtp = mock.MagicMock()
|
||||||
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
mock_smtp.send_message.side_effect = smtplib.SMTPException(
|
||||||
|
"Something went wrong"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"):
|
||||||
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
|
def test_send_includes_cc_and_bcc(self, tmp_path):
|
||||||
|
report = tmp_path / "report.xlsx"
|
||||||
|
report.write_text("fake content")
|
||||||
|
cfg = _make_email_config(to=["a@x.com"], cc=["cc@x.com"], bcc=["bcc@x.com"])
|
||||||
|
|
||||||
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
|
mock_smtp = mock.MagicMock()
|
||||||
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
|
|
||||||
|
send_report(cfg, str(report), "A", "P", [])
|
||||||
|
|
||||||
|
call_args = mock_smtp.send_message.call_args
|
||||||
|
msg = call_args.args[0]
|
||||||
|
assert msg["To"] == "a@x.com"
|
||||||
|
assert msg["Cc"] == "cc@x.com"
|
||||||
@@ -277,3 +277,27 @@ def test_group_rows_preserves_row_data():
|
|||||||
def test_group_rows_empty():
|
def test_group_rows_empty():
|
||||||
"""Пустой список — пустой словарь."""
|
"""Пустой список — пустой словарь."""
|
||||||
assert group_rows_by_project_and_version([]) == {}
|
assert group_rows_by_project_and_version([]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# -- #65: кастомный перевод статусов --
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_grouped_report_custom_status_translation():
|
||||||
|
"""status_translation переопределяет встроенный словарь (#65)."""
|
||||||
|
issue = MockIssue("P", "S", "New", "v1.0", 1)
|
||||||
|
rows = build_grouped_report([(issue, 1.0)], status_translation={"New": "Новая"})
|
||||||
|
assert rows[0]["status_ru"] == "Новая"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_grouped_report_custom_status_translation_passthrough():
|
||||||
|
"""Статус вне кастомного словаря возвращается как есть (#65)."""
|
||||||
|
issue = MockIssue("P", "S", "Closed", "v1.0", 1)
|
||||||
|
rows = build_grouped_report([(issue, 1.0)], status_translation={"New": "Новая"})
|
||||||
|
assert rows[0]["status_ru"] == "Closed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_grouped_report_none_translation_uses_builtin():
|
||||||
|
"""status_translation=None — используется встроенный словарь (#65)."""
|
||||||
|
issue = MockIssue("P", "S", "Closed", "v1.0", 1)
|
||||||
|
rows = build_grouped_report([(issue, 1.0)], status_translation=None)
|
||||||
|
assert rows[0]["status_ru"] == "Закрыто"
|
||||||
|
|||||||
350
tests/test_task_list.py
Normal file
350
tests/test_task_list.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"""Тесты доменного ядра режима --my-tasks (#71): строки, сортировка, саммари."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from redmine_reporter.task_list import (
|
||||||
|
EXCLUDED_VERSION_NAMES,
|
||||||
|
PRIORITY_RANK,
|
||||||
|
build_task_rows,
|
||||||
|
calculate_task_summary,
|
||||||
|
filter_excluded_versions,
|
||||||
|
format_summary_line,
|
||||||
|
priority_rank,
|
||||||
|
)
|
||||||
|
|
||||||
|
TODAY = date(2026, 10, 28)
|
||||||
|
|
||||||
|
|
||||||
|
def make_issue(
|
||||||
|
issue_id,
|
||||||
|
priority="P3",
|
||||||
|
status="New",
|
||||||
|
fixed_version=None,
|
||||||
|
subject="Задача",
|
||||||
|
due=None,
|
||||||
|
spent_hours=0.0,
|
||||||
|
estimated_hours=None,
|
||||||
|
):
|
||||||
|
"""Собирает mock-issue с nested-атрибутами priority.name / fixed_version.name.
|
||||||
|
|
||||||
|
fixed_version=None присваивается явно: unset-атрибут MagicMock был бы
|
||||||
|
автосконструирован, и getattr() вернул бы его вместо безопасного дефолта.
|
||||||
|
"""
|
||||||
|
issue = mock.MagicMock()
|
||||||
|
issue.id = issue_id
|
||||||
|
issue.subject = subject
|
||||||
|
issue.status = status
|
||||||
|
issue.priority.name = priority
|
||||||
|
if fixed_version is None:
|
||||||
|
issue.fixed_version = None
|
||||||
|
else:
|
||||||
|
issue.fixed_version.name = fixed_version
|
||||||
|
issue.spent_hours = spent_hours
|
||||||
|
# None присваивается явно, чтобы getattr() в реализации не наткнулся
|
||||||
|
# на автосконструированный MagicMock-атрибут.
|
||||||
|
issue.estimated_hours = estimated_hours
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
# -- Приоритеты --
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_rank_table():
|
||||||
|
assert PRIORITY_RANK == {"P1": 1, "P2": 2, "P3": 3, "P4": 4, "P5": 5}
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_rank_known():
|
||||||
|
assert priority_rank("P1") == 1
|
||||||
|
assert priority_rank("P5") == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_rank_unknown_is_lowest():
|
||||||
|
"""Неизвестное имя приоритета получает ранг ниже P5."""
|
||||||
|
assert priority_rank("P9") == 6
|
||||||
|
assert priority_rank("") == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_sorted_by_priority():
|
||||||
|
"""P1 раньше P2 раньше P4; неизвестный P9 -- после P5."""
|
||||||
|
items = [
|
||||||
|
(make_issue(4, priority="P4"), None),
|
||||||
|
(make_issue(9, priority="P9"), None),
|
||||||
|
(make_issue(2, priority="P2"), None),
|
||||||
|
(make_issue(5, priority="P5"), None),
|
||||||
|
(make_issue(1, priority="P1"), None),
|
||||||
|
]
|
||||||
|
rows = build_task_rows(items, {}, today=TODAY)
|
||||||
|
assert [r["issue_id"] for r in rows] == [1, 2, 4, 5, 9]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Сортировка внутри приоритетной группы --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_due_ascending_within_priority():
|
||||||
|
items = [
|
||||||
|
(make_issue(2, priority="P2"), date(2026, 3, 10)),
|
||||||
|
(make_issue(1, priority="P2"), date(2026, 1, 1)),
|
||||||
|
(make_issue(3, priority="P2"), date(2026, 2, 15)),
|
||||||
|
]
|
||||||
|
rows = build_task_rows(items, {}, today=TODAY)
|
||||||
|
assert [r["issue_id"] for r in rows] == [1, 3, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_without_due_go_last_and_tiebreak_by_id():
|
||||||
|
"""Задачи без срока -- в конец своей группы; между ними tie-break по id."""
|
||||||
|
items = [
|
||||||
|
(make_issue(25, fixed_version="v2"), None), # версия есть, срока нет
|
||||||
|
(make_issue(30, fixed_version="v1"), date(2026, 2, 1)),
|
||||||
|
(make_issue(12), None), # версии нет, срока нет
|
||||||
|
]
|
||||||
|
rows = build_task_rows(items, {}, today=TODAY)
|
||||||
|
assert [r["issue_id"] for r in rows] == [30, 12, 25]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Фильтр версий-исключений (Wishlist) --
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluded_version_names_constant():
|
||||||
|
"""Точный состав бакет-версий, исключаемых из --my-tasks."""
|
||||||
|
assert EXCLUDED_VERSION_NAMES == frozenset({"Wishlist"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_excludes_wishlist_issue():
|
||||||
|
"""Задача в версии Wishlist удаляется из выборки."""
|
||||||
|
items = [
|
||||||
|
(make_issue(1, fixed_version="Wishlist"), None),
|
||||||
|
(make_issue(2, fixed_version="v2.5.0"), date(2026, 11, 1)),
|
||||||
|
]
|
||||||
|
kept = filter_excluded_versions(items)
|
||||||
|
assert [issue.id for issue, _ in kept] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_keeps_issue_without_version():
|
||||||
|
"""Задача без версии фильтром не трогается."""
|
||||||
|
items = [(make_issue(3), None)]
|
||||||
|
kept = filter_excluded_versions(items)
|
||||||
|
assert [issue.id for issue, _ in kept] == [3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_keeps_regular_version_without_due():
|
||||||
|
"""Обычная версия без срока -- не бакет, задача остаётся."""
|
||||||
|
items = [(make_issue(4, fixed_version="v3.0.0"), None)]
|
||||||
|
kept = filter_excluded_versions(items)
|
||||||
|
assert [issue.id for issue, _ in kept] == [4]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_is_case_sensitive():
|
||||||
|
"""Сравнение имени версии точное: wishlist строчными -- НЕ исключение."""
|
||||||
|
items = [
|
||||||
|
(make_issue(5, fixed_version="wishlist"), None),
|
||||||
|
(make_issue(6, fixed_version="WISHLIST"), None),
|
||||||
|
(make_issue(7, fixed_version="Wishlist"), None),
|
||||||
|
]
|
||||||
|
kept = filter_excluded_versions(items)
|
||||||
|
assert [issue.id for issue, _ in kept] == [5, 6]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_returns_only_excluded_when_all_match():
|
||||||
|
items = [(make_issue(8, fixed_version="Wishlist"), None)]
|
||||||
|
assert filter_excluded_versions(items) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_preserves_due_pairing():
|
||||||
|
"""Фильтр не теряет парную к задаче дату срока."""
|
||||||
|
due = date(2026, 12, 1)
|
||||||
|
items = [(make_issue(9, fixed_version="v1"), due)]
|
||||||
|
((issue, kept_due),) = filter_excluded_versions(items)
|
||||||
|
assert issue.id == 9
|
||||||
|
assert kept_due is due
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_tolerates_version_without_name_attribute():
|
||||||
|
"""fixed_version без атрибута name -- задача остаётся (getattr-безопасность)."""
|
||||||
|
issue = make_issue(10)
|
||||||
|
issue.fixed_version = mock.MagicMock(spec=[])
|
||||||
|
items = [(issue, None)]
|
||||||
|
kept = filter_excluded_versions(items)
|
||||||
|
assert [i.id for i, _ in kept] == [10]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Просроченность --
|
||||||
|
|
||||||
|
|
||||||
|
def test_overdue_yesterday():
|
||||||
|
rows = build_task_rows([(make_issue(1), date(2026, 10, 27))], {}, today=TODAY)
|
||||||
|
assert rows[0]["overdue"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_overdue_today():
|
||||||
|
"""Срок сегодня -- НЕ просрочен (строгое сравнение)."""
|
||||||
|
rows = build_task_rows([(make_issue(1), date(2026, 10, 28))], {}, today=TODAY)
|
||||||
|
assert rows[0]["overdue"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_overdue_tomorrow():
|
||||||
|
rows = build_task_rows([(make_issue(1), date(2026, 10, 29))], {}, today=TODAY)
|
||||||
|
assert rows[0]["overdue"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_overdue_without_due():
|
||||||
|
rows = build_task_rows([(make_issue(1), None)], {}, today=TODAY)
|
||||||
|
assert rows[0]["overdue"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# -- Поля строки --
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_fields():
|
||||||
|
issue = make_issue(
|
||||||
|
42,
|
||||||
|
priority="P1",
|
||||||
|
status="New",
|
||||||
|
fixed_version="v2.5.0",
|
||||||
|
subject="Починить баг",
|
||||||
|
)
|
||||||
|
rows = build_task_rows([(issue, date(2026, 10, 27))], {}, today=TODAY)
|
||||||
|
row = rows[0]
|
||||||
|
assert row["issue_id"] == 42
|
||||||
|
assert row["subject"] == "Починить баг"
|
||||||
|
assert row["priority"] == "P1"
|
||||||
|
assert row["version_name"] == "v2.5.0"
|
||||||
|
assert row["due_text"] == "2026-10-27"
|
||||||
|
assert row["overdue"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_empty_version_and_due_text():
|
||||||
|
"""Без версии и без срока в строке пустые строки, а не заглушки."""
|
||||||
|
rows = build_task_rows([(make_issue(7), None)], {}, today=TODAY)
|
||||||
|
assert rows[0]["version_name"] == ""
|
||||||
|
assert rows[0]["due_text"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
# -- Перевод статусов --
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_translated_by_dict():
|
||||||
|
issue = make_issue(1, status="New")
|
||||||
|
rows = build_task_rows([(issue, None)], {"New": "Новая"}, today=TODAY)
|
||||||
|
assert rows[0]["status_ru"] == "Новая"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_unknown_passthrough():
|
||||||
|
issue = make_issue(1, status="SomeNewStatus")
|
||||||
|
rows = build_task_rows([(issue, None)], {"New": "Новая"}, today=TODAY)
|
||||||
|
assert rows[0]["status_ru"] == "SomeNewStatus"
|
||||||
|
|
||||||
|
|
||||||
|
# -- Саммари --
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_mixed_items():
|
||||||
|
"""Смесь: без версии / версия без срока / просроченная / с оценками и без."""
|
||||||
|
items = [
|
||||||
|
# без версии, без срока, без оценки
|
||||||
|
(make_issue(1, spent_hours=1.5), None),
|
||||||
|
# версия есть, срока нет, оценка есть
|
||||||
|
(make_issue(2, fixed_version="v1", spent_hours=2.0, estimated_hours=4.0), None),
|
||||||
|
# просроченная, оценка есть
|
||||||
|
(
|
||||||
|
make_issue(3, fixed_version="v2", spent_hours=0.5, estimated_hours=3.0),
|
||||||
|
date(2026, 10, 27),
|
||||||
|
),
|
||||||
|
# не просроченная, оценка None
|
||||||
|
(
|
||||||
|
make_issue(4, fixed_version="v3", spent_hours=0.0),
|
||||||
|
date(2026, 10, 29),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
summary = calculate_task_summary(items, today=TODAY)
|
||||||
|
|
||||||
|
assert summary["total"] == 4
|
||||||
|
assert summary["overdue"] == 1
|
||||||
|
assert summary["no_version"] == 1
|
||||||
|
assert summary["no_due"] == 1
|
||||||
|
assert summary["spent_hours"] == 4.0
|
||||||
|
assert summary["estimated_hours"] == 7.0
|
||||||
|
assert summary["estimated_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_empty():
|
||||||
|
summary = calculate_task_summary([], today=TODAY)
|
||||||
|
assert summary == {
|
||||||
|
"total": 0,
|
||||||
|
"overdue": 0,
|
||||||
|
"no_version": 0,
|
||||||
|
"no_due": 0,
|
||||||
|
"spent_hours": 0.0,
|
||||||
|
"estimated_hours": 0.0,
|
||||||
|
"estimated_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_missing_attributes_default_to_zero():
|
||||||
|
"""У issue без атрибутов spent_hours/estimated_hours безопасные нули."""
|
||||||
|
issue = mock.MagicMock(spec=["id", "subject", "status", "priority"])
|
||||||
|
issue.id = 5
|
||||||
|
issue.status = "New"
|
||||||
|
issue.priority.name = "P3"
|
||||||
|
|
||||||
|
summary = calculate_task_summary([(issue, None)], today=TODAY)
|
||||||
|
|
||||||
|
assert summary["spent_hours"] == 0.0
|
||||||
|
assert summary["estimated_hours"] == 0.0
|
||||||
|
assert summary["estimated_count"] == 0
|
||||||
|
assert summary["no_version"] == 1
|
||||||
|
assert summary["no_due"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# -- Строка саммари --
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_summary_line_with_estimates():
|
||||||
|
summary = {
|
||||||
|
"total": 4,
|
||||||
|
"overdue": 1,
|
||||||
|
"no_version": 1,
|
||||||
|
"no_due": 1,
|
||||||
|
"spent_hours": 4.0,
|
||||||
|
"estimated_hours": 7.0,
|
||||||
|
"estimated_count": 2,
|
||||||
|
}
|
||||||
|
line = format_summary_line(summary)
|
||||||
|
assert line == (
|
||||||
|
"Открытых: 4 | просроченных: 1 | без версии: 1 | без срока: 1 "
|
||||||
|
"| потрачено: 4h | оценка: 7h (по 2 из 4)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_summary_line_without_estimates():
|
||||||
|
summary = {
|
||||||
|
"total": 3,
|
||||||
|
"overdue": 0,
|
||||||
|
"no_version": 2,
|
||||||
|
"no_due": 1,
|
||||||
|
"spent_hours": 2.5,
|
||||||
|
"estimated_hours": 0.0,
|
||||||
|
"estimated_count": 0,
|
||||||
|
}
|
||||||
|
line = format_summary_line(summary)
|
||||||
|
assert "оценка" not in line
|
||||||
|
assert line == (
|
||||||
|
"Открытых: 3 | просроченных: 0 | без версии: 2 | без срока: 1 | потрачено: 2.5h"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_summary_line_fractional_hours_use_g_format():
|
||||||
|
"""Часы форматируются :g -- без хвостовых нулей."""
|
||||||
|
summary = {
|
||||||
|
"total": 1,
|
||||||
|
"overdue": 0,
|
||||||
|
"no_version": 0,
|
||||||
|
"no_due": 0,
|
||||||
|
"spent_hours": 21.5,
|
||||||
|
"estimated_hours": 7.25,
|
||||||
|
"estimated_count": 1,
|
||||||
|
}
|
||||||
|
line = format_summary_line(summary)
|
||||||
|
assert "21.5h" in line
|
||||||
|
assert "7.25h" in line
|
||||||
|
assert "21.500000" not in line
|
||||||
161
tests/test_task_table.py
Normal file
161
tests/test_task_table.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Тесты консольного форматтера таблицы режима --my-tasks (#71)."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from redmine_reporter.formatters.task_table import TaskTableFormatter
|
||||||
|
from redmine_reporter.types import TaskRow
|
||||||
|
|
||||||
|
BASE_URL = "https://red.eltex.loc"
|
||||||
|
|
||||||
|
# OSC 8-гиперссылка: esc-последовательность до текста, текст, закрытие ссылки.
|
||||||
|
OSC8_RE = re.compile(r"\x1b\]8;;[^\x1b]*\x1b\\")
|
||||||
|
|
||||||
|
|
||||||
|
def make_row(**overrides) -> TaskRow:
|
||||||
|
row: TaskRow = {
|
||||||
|
"issue_id": 42,
|
||||||
|
"subject": "Починить баг",
|
||||||
|
"status_ru": "Новая",
|
||||||
|
"priority": "P2",
|
||||||
|
"version_name": "v2.5.0",
|
||||||
|
"due_text": "2026-10-15",
|
||||||
|
"overdue": False,
|
||||||
|
}
|
||||||
|
# **-распаковка dict[str, Any] в TypedDict-литерале mypy не поддерживает,
|
||||||
|
# а kwargs-словарь типизируется только как dict[str, Any], поэтому приведение.
|
||||||
|
row.update(cast(TaskRow, overrides))
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def expected_hyperlink(issue_id, base_url=BASE_URL):
|
||||||
|
return f"\x1b]8;;{base_url}/issues/{issue_id}\x1b\\{issue_id}\x1b]8;;\x1b\\"
|
||||||
|
|
||||||
|
|
||||||
|
# -- OSC 8-гиперссылка на ID --
|
||||||
|
|
||||||
|
|
||||||
|
def test_id_wrapped_in_exact_osc8_sequence():
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
|
||||||
|
assert expected_hyperlink(42) in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_url_trailing_slash_not_duplicated():
|
||||||
|
"""base_url с завершающим слэшем не даёт двойного слэша перед /issues."""
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL + "/").format([make_row()])
|
||||||
|
assert expected_hyperlink(42) in output
|
||||||
|
assert "//issues/" not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_hyperlink_text_is_issue_number_only():
|
||||||
|
"""Текст ссылки -- только номер, тема не попадает внутрь OSC 8."""
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format(
|
||||||
|
[make_row(issue_id=7, subject="Тема задачи")]
|
||||||
|
)
|
||||||
|
assert expected_hyperlink(7) in output
|
||||||
|
assert "Тема задачи" in output
|
||||||
|
start = output.index("Тема задачи")
|
||||||
|
assert "\x1b" not in output[:start].replace(expected_hyperlink(7), "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_hyperlink_uses_row_issue_id():
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(issue_id=1234)])
|
||||||
|
assert expected_hyperlink(1234) in output
|
||||||
|
|
||||||
|
|
||||||
|
# -- Заполнение пустых полей --
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_version_rendered_as_dash():
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(version_name="")])
|
||||||
|
assert "-" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_due_rendered_as_dash():
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(due_text="")])
|
||||||
|
assert "-" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonempty_fields_not_replaced_by_dash():
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
|
||||||
|
assert "v2.5.0" in output
|
||||||
|
assert "2026-10-15" in output
|
||||||
|
|
||||||
|
|
||||||
|
# -- Маркер просрочки --
|
||||||
|
|
||||||
|
|
||||||
|
def test_overdue_due_appends_marker():
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format(
|
||||||
|
[make_row(due_text="2026-10-15", overdue=True)]
|
||||||
|
)
|
||||||
|
assert "2026-10-15 (!)" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_overdue_due_without_marker():
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format(
|
||||||
|
[make_row(due_text="2026-10-15", overdue=False)]
|
||||||
|
)
|
||||||
|
assert "2026-10-15" in output
|
||||||
|
assert "(!)" not in output
|
||||||
|
|
||||||
|
|
||||||
|
# -- Заголовки и содержимое --
|
||||||
|
|
||||||
|
|
||||||
|
def test_column_headers_present():
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
|
||||||
|
for header in ("ID", "Тема", "Статус", "Приор.", "Версия", "Срок"):
|
||||||
|
assert header in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_passed_through_as_is():
|
||||||
|
"""status_ru из TaskRow попадает в таблицу без изменений."""
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format(
|
||||||
|
[make_row(status_ru="В работе")]
|
||||||
|
)
|
||||||
|
assert "В работе" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_subject_and_priority_present():
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format(
|
||||||
|
[make_row(subject="Обновить драйвер", priority="P1")]
|
||||||
|
)
|
||||||
|
assert "Обновить драйвер" in output
|
||||||
|
assert "P1" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_rows_rendered():
|
||||||
|
rows = [make_row(issue_id=1), make_row(issue_id=2)]
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format(rows)
|
||||||
|
assert expected_hyperlink(1) in output
|
||||||
|
assert expected_hyperlink(2) in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_rows_renders_headers_only():
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format([])
|
||||||
|
assert "ID" in output
|
||||||
|
assert expected_hyperlink(42) not in output
|
||||||
|
|
||||||
|
|
||||||
|
# -- ESC только внутри ссылок --
|
||||||
|
|
||||||
|
|
||||||
|
def test_esc_only_inside_hyperlinks():
|
||||||
|
"""После вырезания OSC 8-ссылок в строке не остаётся ESC-символов."""
|
||||||
|
rows = [
|
||||||
|
make_row(issue_id=1),
|
||||||
|
make_row(issue_id=2, version_name=""),
|
||||||
|
make_row(issue_id=3, due_text="", overdue=True),
|
||||||
|
]
|
||||||
|
output = TaskTableFormatter(base_url=BASE_URL).format(rows)
|
||||||
|
cleaned = OSC8_RE.sub("", output)
|
||||||
|
assert "\x1b" not in cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_raises_not_implemented():
|
||||||
|
formatter = TaskTableFormatter(base_url=BASE_URL)
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
formatter.save([], "unused.xlsx")
|
||||||
@@ -110,3 +110,21 @@ def test_get_version_falls_back_to_str_when_no_name():
|
|||||||
fixed_version = MockVersionNoName()
|
fixed_version = MockVersionNoName()
|
||||||
|
|
||||||
assert get_version(MockIssue()) == "fallback-id"
|
assert get_version(MockIssue()) == "fallback-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_custom_default():
|
||||||
|
"""Явный default заменяет <N/A>, когда версия не назначена."""
|
||||||
|
|
||||||
|
class MockIssue:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert get_version(MockIssue(), default="") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_custom_default_not_used_when_version_present():
|
||||||
|
"""При наличии версии default не применяется."""
|
||||||
|
|
||||||
|
class MockIssue:
|
||||||
|
fixed_version = "v1.0"
|
||||||
|
|
||||||
|
assert get_version(MockIssue(), default="") == "v1.0"
|
||||||
|
|||||||
278
tests/test_yaml_config.py
Normal file
278
tests/test_yaml_config.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from redmine_reporter.yaml_config import (
|
||||||
|
check_file_permissions,
|
||||||
|
ensure_config_dir,
|
||||||
|
expand_filename_template,
|
||||||
|
resolve_env_vars,
|
||||||
|
save_period_to_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_period_from_in_empty_config(self, tmp_path):
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text("")
|
||||||
|
|
||||||
|
save_period_to_config(str(config_path), "2026-06-01")
|
||||||
|
|
||||||
|
with open(config_path) as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
assert data["period"]["from"] == "2026-06-01"
|
||||||
|
|
||||||
|
def test_overwrites_existing_period_from(self, tmp_path):
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text("period:\n from: '2026-05-01'\n")
|
||||||
|
|
||||||
|
save_period_to_config(str(config_path), "2026-06-01")
|
||||||
|
|
||||||
|
with open(config_path) as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
assert data["period"]["from"] == "2026-06-01"
|
||||||
|
|
||||||
|
def test_preserves_other_sections_and_keys(self, tmp_path):
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"redmine:\n"
|
||||||
|
" url: https://example.com\n"
|
||||||
|
" author: Test\n"
|
||||||
|
"email:\n"
|
||||||
|
" enabled: true\n"
|
||||||
|
"output:\n"
|
||||||
|
" dir: /tmp\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
save_period_to_config(str(config_path), "2026-06-01")
|
||||||
|
|
||||||
|
with open(config_path) as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
assert data["redmine"] == {"url": "https://example.com", "author": "Test"}
|
||||||
|
assert data["email"] == {"enabled": True}
|
||||||
|
assert data["output"] == {"dir": "/tmp"}
|
||||||
|
assert data["period"] == {"from": "2026-06-01"}
|
||||||
|
|
||||||
|
def test_sets_permissions_0600(self, tmp_path):
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
|
||||||
|
save_period_to_config(str(config_path), "2026-06-01")
|
||||||
|
|
||||||
|
mode = config_path.stat().st_mode & 0o777
|
||||||
|
assert mode == 0o600
|
||||||
|
|
||||||
|
def test_creates_config_file_and_dir_when_missing(self, tmp_path):
|
||||||
|
config_path = tmp_path / "nested" / "config.yml"
|
||||||
|
assert not config_path.exists()
|
||||||
|
|
||||||
|
save_period_to_config(str(config_path), "2026-06-01")
|
||||||
|
|
||||||
|
assert config_path.exists()
|
||||||
|
with open(config_path) as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
assert data["period"]["from"] == "2026-06-01"
|
||||||
Reference in New Issue
Block a user