Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
||||||
309
README.md
309
README.md
@@ -1,32 +1,33 @@
|
|||||||
# 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`).
|
||||||
- Разбивка по типам активности (`--by-activity`).
|
- Гибкий выбор периода: `--date`, переменные окружения, YAML-конфиг, по умолчанию — текущий месяц.
|
||||||
- Вывод в консоль (таблица / компактный вид).
|
- `--commit`: сохранение отчёта в файл и фиксация периода в конфиге для следующего запуска.
|
||||||
- Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
|
- `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо).
|
||||||
- Excel-отчёт с merge-ячейками по проекту/версии, итогами, автошириной, автофильтром и закреплённой шапкой.
|
- YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md).
|
||||||
- Сводка по времени (`--summary`).
|
|
||||||
- YAML-конфиг (`~/.config/redmine-reporter/config.yml`): шаблон имени файла, путь по умолчанию, период, email, настройки содержимого отчёта (`report.no_time`).
|
|
||||||
- Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание.
|
|
||||||
- `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска.
|
|
||||||
- `--send`: отправка отчёта по email через SMTP сразу после генерации.
|
|
||||||
- HTML-версия тела письма при `--send`, если включено в YAML-конфиге (`email.html: true`).
|
|
||||||
- Понятные сообщения об ошибках Redmine API, SMTP и файловой системы.
|
|
||||||
- Загрузка альтернативного `.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
|
||||||
@@ -42,260 +43,40 @@ pip install .
|
|||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Настройка
|
## Быстрый старт
|
||||||
|
|
||||||
Источники конфигурации (от высшего приоритета к низшему):
|
|
||||||
|
|
||||||
```
|
|
||||||
CLI-флаги > переменные окружения > .env > YAML-конфиг > кодовые дефолты
|
|
||||||
```
|
|
||||||
|
|
||||||
### YAML-конфиг (основной способ)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Сгенерировать YAML из текущего .env
|
# Сгенерировать конфиг ~/.config/redmine-reporter/config.yml
|
||||||
redmine-reporter --init-config
|
redmine-reporter --init-config
|
||||||
|
|
||||||
# Редактировать под себя
|
# Заполнить redmine.url, redmine.api_key (или ${REDMINE_API_KEY}), redmine.author
|
||||||
vim ~/.config/redmine-reporter/config.yml
|
vim ~/.config/redmine-reporter/config.yml
|
||||||
```
|
|
||||||
|
|
||||||
Структура:
|
# Отчёт за текущий месяц в консоль
|
||||||
|
|
||||||
```yaml
|
|
||||||
redmine:
|
|
||||||
url: https://red.eltex.loc
|
|
||||||
api_key: ${REDMINE_API_KEY}
|
|
||||||
author: "Кокос А.А."
|
|
||||||
verify_ssl: true
|
|
||||||
|
|
||||||
period:
|
|
||||||
precision: date # date | datetime
|
|
||||||
default_from: "2026-06-01"
|
|
||||||
default_to: "2026-06-30"
|
|
||||||
dynamic: false
|
|
||||||
# last_used заполняется --commit (см. docs/CONFIG.md)
|
|
||||||
|
|
||||||
output:
|
|
||||||
dir: ~/reports
|
|
||||||
filename: "{author}_{from}_{to}.{ext}"
|
|
||||||
default_format: xlsx
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Шаблон `output.filename` поддерживает `{author}`, `{from}`, `{to}`, `{date}` (DD_MM_YYYY), `{ext}`.
|
|
||||||
|
|
||||||
Шаблоны `email.subject` и `email.body_text` поддерживают `{author}`, `{period}` (строка диапазона, например `2026-06-01--2026-06-30`).
|
|
||||||
|
|
||||||
Подробнее: [docs/CONFIG.md](docs/CONFIG.md).
|
|
||||||
|
|
||||||
### `.env` (legacy)
|
|
||||||
|
|
||||||
```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_URL` | Да | URL Redmine. |
|
|
||||||
| `REDMINE_API_KEY` | Да, если нет логина и пароля | Redmine API token. |
|
|
||||||
| `REDMINE_USER` | Да, если нет токена | Логин Redmine. |
|
|
||||||
| `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. |
|
|
||||||
| `REDMINE_AUTHOR` | Нет | Имя автора для отчёта. |
|
|
||||||
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). |
|
|
||||||
| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию (`YYYY-MM-DD`). |
|
|
||||||
| `REDMINE_VERIFY` | Нет | TLS-проверка: `true` / `false` / путь к CA bundle. |
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source .venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Основные сценарии
|
|
||||||
|
|
||||||
Отчёт за период по умолчанию:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter
|
|
||||||
```
|
|
||||||
|
|
||||||
Произвольный период:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --date 2026-02-01--2026-02-28
|
|
||||||
```
|
|
||||||
|
|
||||||
Другой пользователь:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --user-id 42
|
|
||||||
redmine-reporter --user-login ivanov
|
|
||||||
redmine-reporter --user-name "Иванов И.И."
|
|
||||||
```
|
|
||||||
|
|
||||||
Переопределить URL / API-ключ:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --url https://red.example.com --api-key ваш_токен
|
|
||||||
```
|
|
||||||
|
|
||||||
Альтернативный `.env`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --config /path/to/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Компактный / отладочный вывод:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --compact
|
|
||||||
redmine-reporter --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### Экспорт в файл
|
|
||||||
|
|
||||||
Явный путь:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --output report.xlsx
|
|
||||||
redmine-reporter --output /path/to/report.odt
|
|
||||||
```
|
|
||||||
|
|
||||||
Только формат (путь и имя берутся из YAML-шаблона):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --output xlsx # → output.dir/отчёт_01_07_2026.xlsx
|
|
||||||
redmine-reporter --output odt # → output.dir/отчёт_01_07_2026.odt
|
|
||||||
```
|
|
||||||
|
|
||||||
Путь без расширения (дописывается `default_format` из конфига):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --output /tmp/report # → /tmp/report.xlsx (если default_format: xlsx)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Отправка по email (`--send`)
|
|
||||||
|
|
||||||
Отправить отчёт на email, указанный в YAML-конфиге (секция `email`):
|
|
||||||
|
|
||||||
```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 --date 2026-06-01--2026-06-30 --send --commit
|
|
||||||
```
|
|
||||||
|
|
||||||
Если в секции `email` установить `html: true`, письмо будет отправлено в двух версиях: plain-text и HTML (таблица отчёта прямо в теле письма). Файл отчёта всё равно прикрепляется, если `attach: true`.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
email:
|
|
||||||
html: true
|
|
||||||
```
|
|
||||||
|
|
||||||
Если секция `email` не настроена — ошибка с пояснением. При ошибке SMTP файл отчёта остаётся на диске, данные не теряются. Поддерживаются `to`, `cc`, `bcc`, TLS, отключение вложения (`attach: false`).
|
|
||||||
|
|
||||||
### Фиксация периода (`--commit`)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Сгенерировать, сохранить в файл по шаблону, запомнить период
|
|
||||||
redmine-reporter --commit
|
|
||||||
|
|
||||||
# С явным путём
|
|
||||||
redmine-reporter --commit --output report.xlsx
|
|
||||||
|
|
||||||
# Следующий запуск (без --date) возьмёт следующий период автоматически
|
|
||||||
redmine-reporter
|
redmine-reporter
|
||||||
|
|
||||||
# При precision=datetime запоминает момент времени
|
# Сохранить в файл и зафиксировать период для следующего запуска
|
||||||
# (предотвращает дублирование записей внутри дня)
|
|
||||||
redmine-reporter --commit
|
redmine-reporter --commit
|
||||||
```
|
```
|
||||||
|
|
||||||
### Сводка и опции
|
## Документация
|
||||||
|
|
||||||
Без времени / с разбивкой по активностям:
|
- [docs/USER_GUIDE.md](docs/USER_GUIDE.md) — руководство пользователя: сценарии использования, справочник всех CLI-флагов, устранение неполадок.
|
||||||
|
- [docs/CONFIG.md](docs/CONFIG.md) — справочник конфигурации: YAML-структура, переменные окружения, приоритеты, безопасность.
|
||||||
```bash
|
|
||||||
redmine-reporter --no-time
|
|
||||||
redmine-reporter --by-activity
|
|
||||||
redmine-reporter --by-activity --summary
|
|
||||||
```
|
|
||||||
|
|
||||||
`--no-time` можно задать в YAML-конфиге (`report.no_time: true`), чтобы автоматические режимы (`--commit`, `--send`) не включали затраченное время без явного флага. При ручном `--output` YAML-значение не применяется — только CLI-флаг `--no-time`.
|
|
||||||
|
|
||||||
Сводка:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redmine-reporter --summary
|
|
||||||
```
|
|
||||||
|
|
||||||
## Форматы вывода
|
## Форматы вывода
|
||||||
|
|
||||||
| Формат | Особенности |
|
| Формат | Особенности |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **ODT** | Заголовок с автором и месяцем, группировка по проекту/версии. |
|
| Консоль | Таблица или компактный вид (`--compact`). |
|
||||||
| **CSV** | UTF-8 с BOM, полные значения `project`/`version` в каждой строке. |
|
| ODT | Требуется `odfpy`; формирование по шаблону. |
|
||||||
| **Markdown** | Компактная таблица, повторяющиеся группы скрыты. |
|
| CSV | UTF-8 с BOM; полные значения `project`/`version` в каждой строке. |
|
||||||
| **HTML** | Полноценный HTML-документ с `meta charset="utf-8"`. |
|
| Markdown | Компактная таблица. |
|
||||||
| **JSON** | Массив объектов: `project`, `version`, `issue_id`, `subject`, `status`, `time`. |
|
| HTML | Полный HTML-документ; объединение ячеек групп через rowspan. |
|
||||||
| **Excel (.xlsx)** | Merge cells, колонки `Hours`/`Spent Time`, итоги, автоширина, автофильтр, freeze panes. |
|
| JSON | Объекты `project`, `version`, `issue_id`, `subject`, `status`, `time` + опционально `activities`. |
|
||||||
|
| XLSX | Объединение ячеек по проекту/версии, итоги, автоширина (максимум 80), автофильтр, freeze panes. |
|
||||||
|
|
||||||
## Полный список флагов
|
Нюанс `--no-time`: физически удаляет колонку времени только CSV; в XLSX колонки остаются, но пустыми и без итогов; в остальных форматах — пустые значения.
|
||||||
|
|
||||||
```
|
|
||||||
--date DATE Диапазон дат: YYYY-MM-DD--YYYY-MM-DD
|
|
||||||
--compact Компактный текстовый вывод вместо таблицы
|
|
||||||
--output PATH/FMT Путь к файлу (.odt/.csv/.md/.html/.json/.xlsx)
|
|
||||||
или bare-формат (xlsx/odt/...) — путь из конфига
|
|
||||||
--author NAME Переопределить имя автора
|
|
||||||
--no-time Не включать затраченное время в таблицу
|
|
||||||
--url URL Переопределить Redmine URL
|
|
||||||
--api-key KEY Переопределить Redmine API key
|
|
||||||
--config PATH Путь к альтернативному .env-файлу
|
|
||||||
--verbose Подробный вывод
|
|
||||||
--debug Отладочный вывод
|
|
||||||
--version Показать версию и выйти
|
|
||||||
--summary Вывести сводку по времени в stderr
|
|
||||||
--user-id ID Redmine ID пользователя для отчёта
|
|
||||||
--user-login LOGIN Логин пользователя Redmine
|
|
||||||
--user-name NAME Полное имя пользователя Redmine
|
|
||||||
--by-activity Разбить время по типам активности
|
|
||||||
--init-config Сгенерировать YAML-конфиг и выйти
|
|
||||||
--force Перезаписать существующий конфиг (с --init-config)
|
|
||||||
--config-path PATH Путь к YAML-конфигу (по умолчанию ~/.config/redmine-reporter/config.yml)
|
|
||||||
--commit Сохранить отчёт в файл и зафиксировать период в конфиге
|
|
||||||
--send Отправить отчёт по email после сохранения
|
|
||||||
```
|
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
@@ -303,15 +84,23 @@ redmine-reporter --summary
|
|||||||
|
|
||||||
```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
|
||||||
ruff format --check redmine_reporter tests
|
ruff format --check redmine_reporter tests
|
||||||
mypy redmine_reporter
|
mypy redmine_reporter
|
||||||
```
|
```
|
||||||
|
|
||||||
|
CI — Gitea Actions (`.gitea/workflows/checks.yaml`): все шесть проверок на матрице Python 3.10–3.13.
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- Не коммитьте `.env`, API token, пароль или логин.
|
- `REDMINE_URL` обязан использовать HTTPS: валидация отклоняет остальное, API-ключ передаётся в заголовках.
|
||||||
- YAML-конфиг имеет права `0600`, директория — `0700`.
|
- `verify_ssl` / `REDMINE_VERIFY`: `true` (по умолчанию — проверка по системному хранилищу CA операционной системы через truststore, корпоративные CA из ОС работают), `false` (предупреждение о MITM-риске при старте) или путь к CA-bundle.
|
||||||
- Рекомендуется хранить секреты через `${VAR}`, а не plaintext.
|
- Конфиг создаётся с правами `0600`, директория — `0700`; при более широких правах выводится предупреждение.
|
||||||
- Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач.
|
- Секреты храните через `${VAR}` в YAML или в переменных окружения, не в открытом виде.
|
||||||
- Инструмент работает только в режиме чтения и не изменяет данные в Redmine.
|
- Инструмент только читает данные из Redmine и ничего в нём не изменяет.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT, см. [LICENSE](LICENSE).
|
||||||
|
|||||||
128
docs/CONFIG.md
128
docs/CONFIG.md
@@ -1,5 +1,33 @@
|
|||||||
# Настройка redmine-reporter
|
# Настройка redmine-reporter
|
||||||
|
|
||||||
|
## Оглавление
|
||||||
|
|
||||||
|
- [Источники конфигурации](#источники-конфигурации)
|
||||||
|
- [YAML-конфиг](#yaml-конфиг)
|
||||||
|
- [Структура](#структура)
|
||||||
|
- [`redmine.verify_ssl` — проверка TLS-сертификата](#redmineverify_ssl--проверка-tls-сертификата)
|
||||||
|
- [`period.precision` — точность периода](#periodprecision--точность-периода)
|
||||||
|
- [`period.default_to` — необязательное окончание периода](#perioddefault_to--необязательное-окончание-периода)
|
||||||
|
- [Период по умолчанию — текущий месяц](#период-по-умолчанию--текущий-месяц)
|
||||||
|
- [`--date` — формат диапазона](#--date--формат-диапазона)
|
||||||
|
- [`--commit` — автофиксация периода](#--commit--автофиксация-периода)
|
||||||
|
- [`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)
|
||||||
|
- [Безопасность](#безопасность)
|
||||||
|
|
||||||
## Источники конфигурации
|
## Источники конфигурации
|
||||||
|
|
||||||
Приоритет, от высшего к низшему:
|
Приоритет, от высшего к низшему:
|
||||||
@@ -8,6 +36,9 @@
|
|||||||
CLI-флаги > переменные окружения > .env > YAML > кодовые дефолты
|
CLI-флаги > переменные окружения > .env > YAML > кодовые дефолты
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Нюанс с `override` при автозагрузке `.env` и `--config` — см.
|
||||||
|
[Сосуществование `.env` и YAML](#сосуществование-env-и-yaml).
|
||||||
|
|
||||||
Если значение не задано на верхнем уровне, берётся уровень ниже. `.env` и YAML
|
Если значение не задано на верхнем уровне, берётся уровень ниже. `.env` и YAML
|
||||||
работают одновременно — можно оставить оба, можно удалить `.env` после миграции.
|
работают одновременно — можно оставить оба, можно удалить `.env` после миграции.
|
||||||
|
|
||||||
@@ -32,6 +63,7 @@ redmine:
|
|||||||
period:
|
period:
|
||||||
precision: date
|
precision: date
|
||||||
default_from: "2026-06-01"
|
default_from: "2026-06-01"
|
||||||
|
# default_to можно не указывать — тогда конец периода будет сегодня
|
||||||
default_to: "2026-06-30"
|
default_to: "2026-06-30"
|
||||||
dynamic: false
|
dynamic: false
|
||||||
last_used:
|
last_used:
|
||||||
@@ -64,15 +96,71 @@ email:
|
|||||||
attach: true
|
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.precision` — точность периода
|
### `period.precision` — точность периода
|
||||||
|
|
||||||
Определяет, как вычисляется следующий период после фиксации:
|
Определяет, как вычисляется следующий период после фиксации:
|
||||||
|
|
||||||
- `date` (по умолчанию) — период с точностью до дня. Следующий запуск (после `--commit`) начинается со следующего дня.
|
- `date` (по умолчанию) — период с точностью до дня. Следующий запуск (после `--commit`) начинается со следующего дня.
|
||||||
- `datetime` — период с точностью до секунды. При повторном запуске time entries с `created_on` и `updated_on` ранее `last_used.to` исключаются (AND-логика: запись исключается только если **оба** поля раньше cutoff). Это предотвращает дублирование при отправке отчёта внутри рабочего дня.
|
- `datetime` — период с точностью до секунды. При повторном запуске time entries, у которых `created_on` или `updated_on` раньше `last_used.to`, исключаются: запись сохраняется, только если **оба** поля не раньше cutoff (AND-логика). Записи без дат (оба поля отсутствуют) сохраняются; если задано только одно поле, проверяется оно. Это предотвращает дублирование при отправке отчёта внутри рабочего дня. Cutoff применяется всегда, когда вычислен (`precision: datetime` и задан `last_used.to`) — в том числе при явном `--date`.
|
||||||
|
|
||||||
`last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется.
|
`last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется.
|
||||||
|
|
||||||
|
### `period.default_to` — необязательное окончание периода
|
||||||
|
|
||||||
|
Если `period.default_to` не задан, а `period.default_from` задан, инструмент
|
||||||
|
использует сегодняшнюю дату в качестве конца периода.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
period:
|
||||||
|
default_from: "2026-07-01"
|
||||||
|
# default_to отсутствует → конец периода = сегодня
|
||||||
|
```
|
||||||
|
|
||||||
|
Это предотвращает устаревание периода, когда отчёт генерируется автоматически
|
||||||
|
(`--send`, `--commit`) без явного `--date`.
|
||||||
|
|
||||||
|
Аналогично работает `DEFAULT_TO_DATE`: если переменная не задана, а
|
||||||
|
`DEFAULT_FROM_DATE` задана, конец периода = сегодня.
|
||||||
|
|
||||||
|
Обратное не работает: `period.default_to` без `period.default_from` (и
|
||||||
|
`DEFAULT_TO_DATE` без `DEFAULT_FROM_DATE`) игнорируется — период определяется
|
||||||
|
остальными источниками.
|
||||||
|
|
||||||
|
### Период по умолчанию — текущий месяц
|
||||||
|
|
||||||
|
Если период не задан ни одним из источников (`--date`, `DEFAULT_FROM_DATE`,
|
||||||
|
`period.default_from`), отчёт строится за текущий месяц: начало периода —
|
||||||
|
1-е число текущего месяца, конец — сегодняшняя дата. Период вычисляется
|
||||||
|
на момент запуска и не хранится в коде или конфиге.
|
||||||
|
|
||||||
|
### `--date` — формат диапазона
|
||||||
|
|
||||||
|
Флаг `--date` принимает два формата:
|
||||||
|
|
||||||
|
- Даты: `YYYY-MM-DD--YYYY-MM-DD` (например, `2026-06-01--2026-06-30`).
|
||||||
|
- Datetime с секундами: `YYYY-MM-DDTHH:MM:SS--YYYY-MM-DDTHH:MM:SS`
|
||||||
|
(например, `2026-06-30T09:00:00--2026-06-30T12:00:00`).
|
||||||
|
|
||||||
|
Обе границы должны быть в одном формате — смешанная точность отвергается.
|
||||||
|
Начало позже конца (`start > end`) — ошибка.
|
||||||
|
|
||||||
### `--commit` — автофиксация периода
|
### `--commit` — автофиксация периода
|
||||||
|
|
||||||
Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода.
|
Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода.
|
||||||
@@ -94,6 +182,9 @@ email:
|
|||||||
- Произвольный диапазон → та же длительность, начиная со дня после `last_used.to`.
|
- Произвольный диапазон → та же длительность, начиная со дня после `last_used.to`.
|
||||||
- `dynamic: false` — `--commit` перезаписывает `default_from`/`default_to` на использованный период.
|
- `dynamic: false` — `--commit` перезаписывает `default_from`/`default_to` на использованный период.
|
||||||
|
|
||||||
|
**Нюанс:** при `--commit` YAML-файл перезаписывается целиком через `yaml.dump` —
|
||||||
|
пользовательские комментарии и ручное форматирование в файле теряются.
|
||||||
|
|
||||||
**Примеры:**
|
**Примеры:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -187,7 +278,8 @@ email:
|
|||||||
3. Формирует MIME-письмо:
|
3. Формирует MIME-письмо:
|
||||||
- Тема, plain-text тело и вложение (если `attach: true`).
|
- Тема, plain-text тело и вложение (если `attach: true`).
|
||||||
- При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта.
|
- При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта.
|
||||||
4. Отправляет через SMTP с TLS (таймаут 30 секунд).
|
4. Отправляет через SMTP (таймаут 30 секунд). STARTTLS включается только при
|
||||||
|
`email.smtp.tls: true`; аутентификация — только если задан `email.smtp.user`.
|
||||||
|
|
||||||
**Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся
|
**Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся
|
||||||
на диске, данные не теряются.
|
на диске, данные не теряются.
|
||||||
@@ -289,6 +381,7 @@ email:
|
|||||||
| Поле | Тип | По умолчанию | Описание |
|
| Поле | Тип | По умолчанию | Описание |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта |
|
| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта |
|
||||||
|
| `status_translation` | map[str,str] | `{}` | Переопределение/дополнение перевода статусов |
|
||||||
|
|
||||||
`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`).
|
`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`).
|
||||||
При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`.
|
При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`.
|
||||||
@@ -312,6 +405,22 @@ redmine-reporter --output report.odt
|
|||||||
redmine-reporter --output report.odt --no-time
|
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()` определяет итоговый путь к файлу:
|
Функция `resolve_output_path()` определяет итоговый путь к файлу:
|
||||||
@@ -340,7 +449,8 @@ vim ~/.config/redmine-reporter/config.yml
|
|||||||
### Что делает `--init-config`
|
### Что делает `--init-config`
|
||||||
|
|
||||||
- Читает текущие значения из `.env` и переменных окружения.
|
- Читает текущие значения из `.env` и переменных окружения.
|
||||||
- Формирует YAML со всеми секциями (`redmine`, `period`, `output`, `email`).
|
- Формирует YAML со всеми секциями (`redmine`, `period`, `output`, `report`, `email`).
|
||||||
|
- В конец файла дописывает закомментированный пример `report.status_translation`.
|
||||||
- Секреты (`REDMINE_API_KEY`, `SMTP_PASSWORD`) записывает как `${VAR}`, если
|
- Секреты (`REDMINE_API_KEY`, `SMTP_PASSWORD`) записывает как `${VAR}`, если
|
||||||
переменная существует, иначе — пустая строка.
|
переменная существует, иначе — пустая строка.
|
||||||
- Создаёт файл с правами `0600`, директорию — с `0700`.
|
- Создаёт файл с правами `0600`, директорию — с `0700`.
|
||||||
@@ -351,7 +461,11 @@ vim ~/.config/redmine-reporter/config.yml
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `--init-config` | Создать YAML и выйти |
|
| `--init-config` | Создать YAML и выйти |
|
||||||
| `--init-config --force` | Перезаписать существующий YAML |
|
| `--init-config --force` | Перезаписать существующий YAML |
|
||||||
| `--config-path PATH` | Сохранить YAML по указанному пути (по умолчанию `~/.config/redmine-reporter/config.yml`) |
|
| `--config-path PATH` | Путь к YAML-конфигу: загрузка при запуске, запись при `--init-config` и `--commit` (по умолчанию `~/.config/redmine-reporter/config.yml`) |
|
||||||
|
|
||||||
|
Если `DEFAULT_TO_DATE` не задана, а `DEFAULT_FROM_DATE` задана, сгенерированный
|
||||||
|
YAML будет содержать пустое `default_to`, и при запуске инструмент использует
|
||||||
|
сегодняшнюю дату.
|
||||||
|
|
||||||
### Проверка после миграции
|
### Проверка после миграции
|
||||||
|
|
||||||
@@ -379,6 +493,10 @@ ls -la ~/.config/redmine-reporter/
|
|||||||
YAML работает как базовый слой для всего, что не в .env
|
YAML работает как базовый слой для всего, что не в .env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Автозагрузка `.env` (без `--config`) не перебивает реальные переменные
|
||||||
|
окружения (`override=False`). Исключение — `--config FILE`: указанный `.env`
|
||||||
|
загружается с `override=True` и перебивает переменные окружения (но не CLI-флаги).
|
||||||
|
|
||||||
Это safe — если с YAML что-то пойдёт не так, просто положи `.env` обратно.
|
Это safe — если с YAML что-то пойдёт не так, просто положи `.env` обратно.
|
||||||
|
|
||||||
### Откат
|
### Откат
|
||||||
@@ -400,7 +518,7 @@ DEFAULT_TO_DATE=2026-06-30
|
|||||||
```
|
```
|
||||||
|
|
||||||
Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц
|
Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц
|
||||||
как период, стандартный путь сертификатов, пустой автор).
|
как период, стандартная проверка TLS, пустой автор).
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
|
|||||||
304
docs/USER_GUIDE.md
Normal file
304
docs/USER_GUIDE.md
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
# Руководство пользователя redmine-reporter
|
||||||
|
|
||||||
|
## Оглавление
|
||||||
|
|
||||||
|
- [Установка](#установка)
|
||||||
|
- [Первоначальная настройка](#первоначальная-настройка)
|
||||||
|
- [Выбор периода](#выбор-периода)
|
||||||
|
- [Отчёт за другого пользователя](#отчёт-за-другого-пользователя)
|
||||||
|
- [Экспорт в файл](#экспорт-в-файл)
|
||||||
|
- [Содержимое отчёта](#содержимое-отчёта)
|
||||||
|
- [Отправка по email](#отправка-по-email)
|
||||||
|
- [Ежемесячный цикл с --commit](#ежемесячный-цикл-с---commit)
|
||||||
|
- [Справочник флагов](#справочник-флагов)
|
||||||
|
- [Устранение неполадок](#устранение-неполадок)
|
||||||
|
- [См. также](#см-также)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Требуется 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).
|
||||||
|
|
||||||
|
## Выбор периода
|
||||||
|
|
||||||
|
Источник периода выбирается по приоритету (от высшего к низшему):
|
||||||
|
|
||||||
|
1. `--date` — явный диапазон.
|
||||||
|
2. `DEFAULT_FROM_DATE` (env/.env) — конец: `DEFAULT_TO_DATE` или сегодня. `DEFAULT_TO_DATE` без `DEFAULT_FROM_DATE` игнорируется.
|
||||||
|
3. `period.dynamic: true` + `period.last_used` в YAML — следующий период после `last_used`:
|
||||||
|
- последний период — полный календарный месяц → следующий полный месяц;
|
||||||
|
- произвольный диапазон → та же длительность, начиная со дня после `last_used.to`;
|
||||||
|
- при `precision: datetime` → та же длительность, сдвинутая на 1 секунду после `last_used.to`.
|
||||||
|
4. `period.default_from` в YAML — конец: `default_to` или сегодня. `default_to` без `default_from` игнорируется.
|
||||||
|
5. Ничего не задано → текущий месяц: с 1-го числа по сегодня.
|
||||||
|
|
||||||
|
Форматы `--date`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Даты
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30
|
||||||
|
|
||||||
|
# Дата-время
|
||||||
|
redmine-reporter --date 2026-06-01T09:00:00--2026-06-01T18:00:00
|
||||||
|
```
|
||||||
|
|
||||||
|
Смешанная точность (дата + дата-время в одном диапазоне) и диапазон, где начало позже конца, отвергаются с ошибкой.
|
||||||
|
|
||||||
|
## Отчёт за другого пользователя
|
||||||
|
|
||||||
|
Без флагов отчёт строится за текущего пользователя.
|
||||||
|
|
||||||
|
```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...».
|
||||||
|
|
||||||
|
## Экспорт в файл
|
||||||
|
|
||||||
|
Флаг `--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 файл остаётся на месте.
|
||||||
|
- Секция `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
|
||||||
|
|
||||||
|
`--commit` сохраняет отчёт в файл (по `--output` или по шаблону `output.dir`/`output.filename`) и записывает `period.last_used` в YAML-конфиг.
|
||||||
|
|
||||||
|
Что пишется:
|
||||||
|
|
||||||
|
- `precision: datetime` → `last_used.from` = `last_used.to` = текущий момент UTC.
|
||||||
|
- `precision: date` → фактические `from`/`to` использованного периода.
|
||||||
|
- `dynamic: true` → `default_from`/`default_to` не меняются.
|
||||||
|
- `dynamic: false` → дополнительно перезаписываются `default_from`/`default_to`.
|
||||||
|
|
||||||
|
Типичный цикл:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Отчёт за июнь 2026: сохранить файл, зафиксировать период
|
||||||
|
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
|
||||||
|
|
||||||
|
# 2. Следующий запуск без --date возьмёт июль 2026 автоматически
|
||||||
|
# (при dynamic: true и полном месяце)
|
||||||
|
redmine-reporter --send --commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Защита от дублей при `precision: datetime`:
|
||||||
|
|
||||||
|
- Если в YAML есть `last_used.to` (cutoff), time entry сохраняется в отчёт, только если `created_on` И `updated_on` оба >= cutoff; если хотя бы одно поле раньше cutoff — запись исключается.
|
||||||
|
- Записи, у которых оба поля (`created_on`, `updated_on`) отсутствуют, сохраняются; если задано только одно поле, решает оно.
|
||||||
|
- Фильтр применяется всегда при `precision: datetime`, в том числе при явном `--date`.
|
||||||
|
|
||||||
|
Важно: `--commit` перезаписывает YAML через `yaml.dump` — пользовательские комментарии в файле теряются.
|
||||||
|
|
||||||
|
## Справочник флагов
|
||||||
|
|
||||||
|
| Флаг | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `--date` | Диапазон периода: `YYYY-MM-DD--YYYY-MM-DD` или `YYYY-MM-DDTHH:MM:SS--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.last_used`) в YAML. |
|
||||||
|
| `--send` | Отправить отчёт по email после сохранения файла. |
|
||||||
|
|
||||||
|
## Устранение неполадок
|
||||||
|
|
||||||
|
| Ошибка | Причина | Решение |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| «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`. |
|
||||||
|
| «Неизвестный формат файла: '.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 отключена | Включите проверку или используйте только в доверенной сети. |
|
||||||
|
| Не виден traceback при ошибке | Traceback показывается только с `--verbose`/`--debug` | Повторите запуск с `--debug`. |
|
||||||
|
|
||||||
|
API-ключ в текстах ошибок маскируется (`***`).
|
||||||
|
|
||||||
|
## См. также
|
||||||
|
|
||||||
|
- [README.md](../README.md) — обзор, быстрый старт, форматы вывода.
|
||||||
|
- [CONFIG.md](CONFIG.md) — полный референс конфигурации (YAML, env, приоритеты, безопасность).
|
||||||
@@ -4,20 +4,19 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "redmine-reporter"
|
name = "redmine-reporter"
|
||||||
version = "1.10.0"
|
version = "1.11.1"
|
||||||
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",
|
||||||
@@ -25,6 +24,9 @@ dependencies = [
|
|||||||
"odfpy>=1.4.0",
|
"odfpy>=1.4.0",
|
||||||
"openpyxl>=3.1.0",
|
"openpyxl>=3.1.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
|
"requests>=2.31",
|
||||||
|
"truststore>=0.10",
|
||||||
|
"urllib3>=1.26",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -32,6 +34,9 @@ dev = [
|
|||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
"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]
|
||||||
@@ -44,9 +49,16 @@ include = ["redmine_reporter*"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
"redmine_reporter" = ["templates/template.odt"]
|
"redmine_reporter" = ["templates/template.odt"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"]
|
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.10.0"
|
__version__ = "1.11.1"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
from dotenv import find_dotenv, load_dotenv
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .client import RedmineAPIError, fetch_issues_with_spent_time
|
from .client import RedmineAPIError, fetch_issues_with_spent_time
|
||||||
@@ -17,6 +18,26 @@ from .mailer import send_report
|
|||||||
from .report_builder import build_grouped_report, calculate_summary
|
from .report_builder import build_grouped_report, calculate_summary
|
||||||
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
|
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
|
||||||
|
|
||||||
|
# Маскируем API-ключ Redmine в тексте ошибок (#55):
|
||||||
|
# - 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_date_range(date_arg: str) -> tuple[str, str]:
|
def parse_date_range(date_arg: str) -> tuple[str, str]:
|
||||||
if "--" not in date_arg:
|
if "--" not in date_arg:
|
||||||
@@ -25,25 +46,41 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
from_date, to_date = parts[0].strip(), parts[1].strip()
|
from_date, to_date = parts[0].strip(), parts[1].strip()
|
||||||
date_pattern = r"\d{4}-\d{2}-\d{2}"
|
date_pattern = r"\d{4}-\d{2}-\d{2}"
|
||||||
if not re.fullmatch(date_pattern, from_date) or not re.fullmatch(
|
datetime_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
|
||||||
date_pattern, to_date
|
if re.fullmatch(date_pattern, from_date) and re.fullmatch(date_pattern, to_date):
|
||||||
|
fmt = "%Y-%m-%d"
|
||||||
|
elif re.fullmatch(datetime_pattern, from_date) and re.fullmatch(
|
||||||
|
datetime_pattern, to_date
|
||||||
):
|
):
|
||||||
|
fmt = "%Y-%m-%dT%H:%M:%S"
|
||||||
|
else:
|
||||||
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
start = datetime.strptime(from_date, "%Y-%m-%d").date()
|
start = datetime.strptime(from_date, fmt)
|
||||||
end = datetime.strptime(to_date, "%Y-%m-%d").date()
|
end = datetime.strptime(to_date, fmt)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise ValueError("Date range contains invalid calendar date") from e
|
raise ValueError("Date range contains invalid calendar date") from e
|
||||||
|
|
||||||
if start > end:
|
if start > end:
|
||||||
raise ValueError("Date range start must be less than or equal to end")
|
raise ValueError("Date range start must be less than or equal to end")
|
||||||
|
|
||||||
|
if fmt == "%Y-%m-%d":
|
||||||
|
return start.date().isoformat(), end.date().isoformat()
|
||||||
return start.isoformat(), end.isoformat()
|
return start.isoformat(), end.isoformat()
|
||||||
|
|
||||||
|
|
||||||
def _run_init_config(config_path: str, force: bool) -> int:
|
def _run_init_config(config_path: str, force: bool) -> int:
|
||||||
"""Создаёт YAML-конфиг из текущих настроек окружения."""
|
"""Создаёт 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)
|
path = Path(config_path)
|
||||||
|
|
||||||
if path.exists() and not force:
|
if path.exists() and not force:
|
||||||
@@ -99,6 +136,16 @@ def _run_init_config(config_path: str, force: bool) -> int:
|
|||||||
yaml.dump(
|
yaml.dump(
|
||||||
data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
|
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)
|
path.chmod(0o600)
|
||||||
|
|
||||||
sections_found = [s for s in data if data[s]]
|
sections_found = [s for s in data if data[s]]
|
||||||
@@ -219,7 +266,7 @@ def _save_and_maybe_send(
|
|||||||
)
|
)
|
||||||
print(f"📧 Report sent to {', '.join(email_config.to)}")
|
print(f"📧 Report sent to {', '.join(email_config.to)}")
|
||||||
except RedmineAPIError as e:
|
except RedmineAPIError as e:
|
||||||
print(f"❌ {e.message}", file=sys.stderr)
|
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
@@ -385,18 +432,16 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
dedup_before=_compute_dedup_cutoff(),
|
dedup_before=_compute_dedup_cutoff(),
|
||||||
)
|
)
|
||||||
except RedmineAPIError as e:
|
except RedmineAPIError as e:
|
||||||
print(f"❌ {e.message}", file=sys.stderr)
|
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
|
||||||
if args.debug and e.original is not None:
|
if (args.verbose or args.debug) and e.original is not None:
|
||||||
logging.exception("Original Redmine API error")
|
logging.exception("Original Redmine API error")
|
||||||
return 1
|
return 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Unexpected error: {e}", file=sys.stderr)
|
print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
|
||||||
|
if args.verbose or args.debug:
|
||||||
|
logging.exception("Unexpected error")
|
||||||
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
|
||||||
@@ -410,6 +455,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
issue_hours,
|
issue_hours,
|
||||||
fill_time=not no_time,
|
fill_time=not no_time,
|
||||||
by_activity=args.by_activity,
|
by_activity=args.by_activity,
|
||||||
|
status_translation=Config.get_status_translation(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.summary:
|
if args.summary:
|
||||||
@@ -521,9 +567,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
dynamic = Config._app.period_dynamic if Config._app else False
|
dynamic = Config._app.period_dynamic if Config._app else False
|
||||||
|
|
||||||
if precision == "datetime":
|
if precision == "datetime":
|
||||||
from datetime import datetime as dt_mod
|
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
|
||||||
|
# метки aware cutoff для дедупликации.
|
||||||
now = dt_mod.now().isoformat(timespec="seconds")
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
from_str = now
|
from_str = now
|
||||||
to_str = now
|
to_str = now
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from datetime import datetime
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import truststore
|
||||||
from redminelib import Redmine
|
from redminelib import Redmine
|
||||||
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
|
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
|
||||||
from redminelib.resources import Issue
|
from redminelib.resources import Issue
|
||||||
@@ -48,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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -121,7 +132,14 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
|
|||||||
try:
|
try:
|
||||||
activities = redmine.enumeration.filter(resource="time_entry_activities")
|
activities = redmine.enumeration.filter(resource="time_entry_activities")
|
||||||
return {int(a.id): str(a.name) for a in activities}
|
return {int(a.id): str(a.name) for a in activities}
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
# #61: не глотаем сбой молча — предупреждаем, что разбивка по
|
||||||
|
# активностям будет построена по сырым данным из самих entries.
|
||||||
|
print(
|
||||||
|
f"⚠️ Could not load time entry activities: {exc}. "
|
||||||
|
"Activity names will be taken from time entries.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@@ -150,20 +168,28 @@ def _parse_datetime(value: Any) -> Optional[datetime]:
|
|||||||
|
|
||||||
Accepts datetime objects, ISO strings (with or without timezone),
|
Accepts datetime objects, ISO strings (with or without timezone),
|
||||||
or None. Returns a timezone-aware datetime or None.
|
or None. Returns a timezone-aware datetime or None.
|
||||||
|
Naive datetimes are treated as UTC (#58).
|
||||||
"""
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
return value
|
return _ensure_aware_utc(value)
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
return dt
|
return _ensure_aware_utc(dt)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_aware_utc(dt: datetime) -> datetime:
|
||||||
|
"""Возвращает aware datetime; naive трактуется как UTC (#58)."""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
|
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
|
||||||
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
|
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
|
||||||
all_issues: List[Issue] = []
|
all_issues: List[Issue] = []
|
||||||
@@ -197,8 +223,18 @@ def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int:
|
|||||||
# Затем ищем по логину
|
# Затем ищем по логину
|
||||||
try:
|
try:
|
||||||
users = redmine.user.filter(login=text)
|
users = redmine.user.filter(login=text)
|
||||||
if users:
|
# Фильтр Redmine по логину неточный (substring-поиск), поэтому
|
||||||
return int(users[0].id)
|
# выбираем только точные регистрозависимые совпадения логина (#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:
|
except Exception as exc:
|
||||||
raise RedmineAPIError(
|
raise RedmineAPIError(
|
||||||
f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}",
|
f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}",
|
||||||
@@ -249,8 +285,11 @@ def fetch_issues_with_spent_time(
|
|||||||
along with total spent hours per issue.
|
along with total spent hours per issue.
|
||||||
If user_id is None, uses current user.
|
If user_id is None, uses current user.
|
||||||
If by_activity is True, returns per-activity breakdown as third tuple element.
|
If by_activity is True, returns per-activity breakdown as third tuple element.
|
||||||
If dedup_before is set, filters out time entries whose created_on AND updated_on
|
If dedup_before is set, entries with both fields set are kept only when
|
||||||
are both before dedup_before (AND logic: both must be < cutoff to exclude).
|
created_on AND updated_on are both >= dedup_before; an entry is excluded
|
||||||
|
if at least one of the fields is before the cutoff (dedup_before).
|
||||||
|
Entries with both fields missing (None) are kept;
|
||||||
|
if only one field is set, that field alone decides (>= cutoff keeps the entry).
|
||||||
Returns list of (issue, total_hours, activities) tuples.
|
Returns list of (issue, total_hours, activities) tuples.
|
||||||
Raises RedmineAPIError on API/auth/network failures.
|
Raises RedmineAPIError on API/auth/network failures.
|
||||||
"""
|
"""
|
||||||
@@ -274,10 +313,17 @@ def fetch_issues_with_spent_time(
|
|||||||
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
||||||
|
|
||||||
# Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте.
|
# Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте.
|
||||||
# Запись исключается, если BOTH created_on AND updated_on < dedup_before.
|
# Если оба поля заданы, запись сохраняется, только если created_on И updated_on
|
||||||
# Записи без метаданных (created_on/updated_on == None) не фильтруются.
|
# оба >= dedup_before; если хотя бы одно из полей < dedup_before,
|
||||||
|
# запись исключается.
|
||||||
|
# Если оба поля None — запись сохраняется; если задано только одно поле,
|
||||||
|
# решает оно (>= dedup_before → запись сохраняется).
|
||||||
if dedup_before is not None:
|
if dedup_before is not None:
|
||||||
|
# Нормализуем cutoff к aware UTC (#58): naive cutoff трактуем как UTC,
|
||||||
|
# чтобы сравнение с нормализованными created_on/updated_on было корректным.
|
||||||
|
dedup_before = _ensure_aware_utc(dedup_before)
|
||||||
filtered: list = []
|
filtered: list = []
|
||||||
|
try:
|
||||||
for entry in time_entries:
|
for entry in time_entries:
|
||||||
created = _parse_datetime(getattr(entry, "created_on", None))
|
created = _parse_datetime(getattr(entry, "created_on", None))
|
||||||
updated = _parse_datetime(getattr(entry, "updated_on", None))
|
updated = _parse_datetime(getattr(entry, "updated_on", None))
|
||||||
@@ -291,6 +337,11 @@ def fetch_issues_with_spent_time(
|
|||||||
filtered.append(entry)
|
filtered.append(entry)
|
||||||
elif updated is not None and updated >= dedup_before:
|
elif updated is not None and updated >= dedup_before:
|
||||||
filtered.append(entry)
|
filtered.append(entry)
|
||||||
|
except TypeError as exc:
|
||||||
|
raise RedmineAPIError(
|
||||||
|
f"Failed to compare time entry dates with deduplication cutoff: {exc}",
|
||||||
|
original=exc,
|
||||||
|
) from exc
|
||||||
time_entries = filtered
|
time_entries = filtered
|
||||||
|
|
||||||
# Агрегируем часы по issue.id (и активности, если требуется)
|
# Агрегируем часы по issue.id (и активности, если требуется)
|
||||||
@@ -319,6 +370,20 @@ def fetch_issues_with_spent_time(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
||||||
|
|
||||||
|
# #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 как есть.
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union
|
from typing import Dict, Union
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from .yaml_config import check_file_permissions, resolve_env_vars
|
from .yaml_config import check_file_permissions, resolve_env_vars
|
||||||
|
|
||||||
load_dotenv(override=False)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
|
DEFAULT_REDMINE_VERIFY: Union[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"}
|
||||||
|
|
||||||
@@ -63,6 +62,7 @@ class AppConfig:
|
|||||||
output_filename: str = "{author}_{from}_{to}.{ext}"
|
output_filename: str = "{author}_{from}_{to}.{ext}"
|
||||||
output_default_format: str = "xlsx"
|
output_default_format: str = "xlsx"
|
||||||
report_no_time: bool = False
|
report_no_time: bool = False
|
||||||
|
report_status_translation: Dict[str, str] = field(default_factory=dict)
|
||||||
email: EmailConfig = field(default_factory=EmailConfig)
|
email: EmailConfig = field(default_factory=EmailConfig)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -112,6 +112,9 @@ class AppConfig:
|
|||||||
output_default_format=cls._resolve_str(raw, "output", "default_format")
|
output_default_format=cls._resolve_str(raw, "output", "default_format")
|
||||||
or "xlsx",
|
or "xlsx",
|
||||||
report_no_time=cls._resolve_bool(raw, "report", "no_time"),
|
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),
|
email=cls._resolve_email(raw),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,6 +144,43 @@ class AppConfig:
|
|||||||
return ""
|
return ""
|
||||||
return str(value)
|
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
|
@staticmethod
|
||||||
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
|
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
|
||||||
value = raw.get(section, {}).get(key)
|
value = raw.get(section, {}).get(key)
|
||||||
@@ -156,17 +196,23 @@ class AppConfig:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
|
def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
|
||||||
|
"""Семантика verify_ssl, единая с REDMINE_VERIFY из env:
|
||||||
|
|
||||||
|
- true (bool или строка) → True — стандартная проверка TLS (requests);
|
||||||
|
- false (bool или строка) → False — проверка отключена;
|
||||||
|
- любая другая строка → путь к CA-bundle как есть.
|
||||||
|
"""
|
||||||
value = raw.get("redmine", {}).get("verify_ssl")
|
value = raw.get("redmine", {}).get("verify_ssl")
|
||||||
if value is None:
|
if value is None:
|
||||||
return DEFAULT_REDMINE_VERIFY
|
return DEFAULT_REDMINE_VERIFY
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return DEFAULT_REDMINE_VERIFY if value else False
|
return value
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
normalized = value.lower()
|
normalized = value.lower()
|
||||||
if normalized in FALSE_VALUES:
|
if normalized in FALSE_VALUES:
|
||||||
return False
|
return False
|
||||||
if normalized in TRUE_VALUES:
|
if normalized in TRUE_VALUES:
|
||||||
return DEFAULT_REDMINE_VERIFY
|
return True
|
||||||
return resolve_env_vars(value)
|
return resolve_env_vars(value)
|
||||||
return DEFAULT_REDMINE_VERIFY
|
return DEFAULT_REDMINE_VERIFY
|
||||||
|
|
||||||
@@ -242,7 +288,13 @@ class Config:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load_yaml(cls, path: str) -> None:
|
def load_yaml(cls, path: str) -> None:
|
||||||
"""Загружает YAML-конфиг. Не бросает исключений при отсутствии файла."""
|
"""Загружает конфигурацию приложения.
|
||||||
|
|
||||||
|
Сначала подгружает .env из текущей директории (override=False:
|
||||||
|
переменные окружения не перебиваются), затем читает YAML-конфиг.
|
||||||
|
Не бросает исключений при отсутствии файла.
|
||||||
|
"""
|
||||||
|
load_dotenv(override=False)
|
||||||
cls._app = AppConfig.from_yaml(path)
|
cls._app = AppConfig.from_yaml(path)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -356,6 +408,18 @@ class Config:
|
|||||||
return cls._app.report_no_time
|
return cls._app.report_no_time
|
||||||
return False
|
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
|
@classmethod
|
||||||
def get_email_config(cls) -> "EmailConfig | None":
|
def get_email_config(cls) -> "EmailConfig | None":
|
||||||
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""
|
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""
|
||||||
@@ -370,8 +434,11 @@ class Config:
|
|||||||
def get_default_date_range(cls) -> str:
|
def get_default_date_range(cls) -> str:
|
||||||
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
|
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
|
||||||
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
|
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
|
||||||
if from_env and to_env:
|
today = date.today()
|
||||||
return f"{from_env}--{to_env}"
|
today_str = today.isoformat()
|
||||||
|
|
||||||
|
if from_env:
|
||||||
|
return f"{from_env}--{to_env or today_str}"
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cls._app
|
cls._app
|
||||||
@@ -386,22 +453,30 @@ class Config:
|
|||||||
)
|
)
|
||||||
return f"{nf}--{nt}"
|
return f"{nf}--{nt}"
|
||||||
|
|
||||||
if cls._app and cls._app.period_default_from and cls._app.period_default_to:
|
if cls._app and cls._app.period_default_from:
|
||||||
return f"{cls._app.period_default_from}--{cls._app.period_default_to}"
|
default_to = cls._app.period_default_to or today_str
|
||||||
|
return f"{cls._app.period_default_from}--{default_to}"
|
||||||
|
|
||||||
today = date.today()
|
|
||||||
start = today.replace(day=1)
|
start = today.replace(day=1)
|
||||||
if today.month == 12:
|
return f"{start.isoformat()}--{today_str}"
|
||||||
next_month = today.replace(year=today.year + 1, month=1, day=1)
|
|
||||||
else:
|
|
||||||
next_month = today.replace(month=today.month + 1, day=1)
|
|
||||||
end = next_month - timedelta(days=1)
|
|
||||||
return f"{start.isoformat()}--{end.isoformat()}"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls) -> None:
|
def validate(cls) -> None:
|
||||||
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()):
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ class XLSXFormatter(Formatter):
|
|||||||
def format(self, rows: List[ReportRow]) -> Workbook:
|
def format(self, rows: List[ReportRow]) -> Workbook:
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
if ws is None:
|
|
||||||
ws = wb.create_sheet("Report")
|
|
||||||
else:
|
|
||||||
ws.title = "Report"
|
ws.title = "Report"
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Отправка сгенерированного отчёта по email через SMTP."""
|
"""Отправка сгенерированного отчёта по email через SMTP."""
|
||||||
|
|
||||||
import email.charset as _charset
|
|
||||||
import os
|
import os
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.application import MIMEApplication
|
from email.mime.application import MIMEApplication
|
||||||
@@ -12,10 +11,6 @@ from .client import RedmineAPIError
|
|||||||
from .config import EmailConfig
|
from .config import EmailConfig
|
||||||
from .types import ReportRow
|
from .types import ReportRow
|
||||||
|
|
||||||
# Use 8bit transfer encoding for UTF-8 so non-ASCII text (e.g. Russian)
|
|
||||||
# appears literally in MIME output instead of base64.
|
|
||||||
_charset.add_charset("utf-8", _charset.SHORTEST, None, "utf-8")
|
|
||||||
|
|
||||||
SMTP_TIMEOUT = 30
|
SMTP_TIMEOUT = 30
|
||||||
|
|
||||||
MIME_TYPES: Dict[str, str] = {
|
MIME_TYPES: Dict[str, str] = {
|
||||||
@@ -34,6 +29,20 @@ def _resolve_mime_type(file_path: str) -> str:
|
|||||||
return MIME_TYPES.get(ext, "application/octet-stream")
|
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:
|
def _build_html_body(rows: List[ReportRow]) -> str:
|
||||||
"""Генерирует HTML-версию тела письма через HTMLFormatter."""
|
"""Генерирует HTML-версию тела письма через HTMLFormatter."""
|
||||||
from .formatters.html import HTMLFormatter
|
from .formatters.html import HTMLFormatter
|
||||||
@@ -66,11 +75,11 @@ def build_message(
|
|||||||
|
|
||||||
# Тела письма: plain-text всегда, HTML по флагу
|
# Тела письма: plain-text всегда, HTML по флагу
|
||||||
body_container = MIMEMultipart("alternative")
|
body_container = MIMEMultipart("alternative")
|
||||||
body_container.attach(MIMEText(body, "plain", "utf-8"))
|
body_container.attach(_utf8_text_part(body))
|
||||||
|
|
||||||
if email_config.html:
|
if email_config.html:
|
||||||
html_body = _build_html_body(rows)
|
html_body = _build_html_body(rows)
|
||||||
body_container.attach(MIMEText(html_body, "html", "utf-8"))
|
body_container.attach(_utf8_text_part(html_body, "html"))
|
||||||
|
|
||||||
msg.attach(body_container)
|
msg.attach(body_container)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ def build_grouped_report(
|
|||||||
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]],
|
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]],
|
||||||
fill_time: bool = True,
|
fill_time: bool = True,
|
||||||
by_activity: bool = False,
|
by_activity: bool = False,
|
||||||
|
status_translation: Optional[Dict[str, str]] = None,
|
||||||
) -> List[ReportRow]:
|
) -> List[ReportRow]:
|
||||||
"""
|
"""
|
||||||
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
||||||
@@ -42,6 +43,11 @@ def build_grouped_report(
|
|||||||
|
|
||||||
Предусловие: issue_hours должен быть отсортирован по (project, version).
|
Предусловие: issue_hours должен быть отсортирован по (project, version).
|
||||||
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
|
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
|
||||||
|
|
||||||
|
status_translation: перевод статусов; None — встроенный STATUS_TRANSLATION.
|
||||||
|
Переданный словарь ЗАМЕНЯЕТ встроенный полностью (без merge): статус,
|
||||||
|
отсутствующий в нём, выводится как есть (passthrough); пустой dict —
|
||||||
|
все статусы выводятся как есть.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
||||||
@@ -49,6 +55,10 @@ def build_grouped_report(
|
|||||||
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
|
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
translation = (
|
||||||
|
status_translation if status_translation is not None else STATUS_TRANSLATION
|
||||||
|
)
|
||||||
|
|
||||||
rows: List[ReportRow] = []
|
rows: List[ReportRow] = []
|
||||||
prev_project: str = ""
|
prev_project: str = ""
|
||||||
prev_version: str = ""
|
prev_version: str = ""
|
||||||
@@ -58,7 +68,7 @@ def build_grouped_report(
|
|||||||
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)
|
||||||
|
|
||||||
if fill_time:
|
if fill_time:
|
||||||
if by_activity and activities:
|
if by_activity and activities:
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ def _reset_config_overrides():
|
|||||||
[
|
[
|
||||||
("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")),
|
("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")),
|
||||||
(" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")),
|
(" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")),
|
||||||
|
(
|
||||||
|
"2026-06-01T00:00:00--2026-06-30T23:59:59",
|
||||||
|
("2026-06-01T00:00:00", "2026-06-30T23:59:59"),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_parse_date_range_valid(date_arg, expected):
|
def test_parse_date_range_valid(date_arg, expected):
|
||||||
@@ -43,6 +47,9 @@ def test_parse_date_range_valid(date_arg, expected):
|
|||||||
"2026-1-01--2026-01-31",
|
"2026-1-01--2026-01-31",
|
||||||
"2026-02-30--2026-03-01",
|
"2026-02-30--2026-03-01",
|
||||||
"2026-02-01--2026-01-31",
|
"2026-02-01--2026-01-31",
|
||||||
|
"2026-06-01T25:00:00--2026-06-30T23:59:59",
|
||||||
|
"2026-06-01--2026-06-30T23:59:59",
|
||||||
|
"2026-06-30T23:59:59--2026-06-01T00:00:00",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_parse_date_range_invalid(date_arg):
|
def test_parse_date_range_invalid(date_arg):
|
||||||
@@ -91,7 +98,7 @@ class _MockIssue:
|
|||||||
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
||||||
"""Неизвестное расширение файла -- выход 1."""
|
"""Неизвестное расширение файла -- выход 1."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
output = str(tmp_path / "report.xyz")
|
output = str(tmp_path / "report.xyz")
|
||||||
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
||||||
assert code == 1
|
assert code == 1
|
||||||
@@ -103,7 +110,7 @@ def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
|||||||
def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
|
def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
|
||||||
"""Файл без расширения — приложение расширяет default_format (#43)."""
|
"""Файл без расширения — приложение расширяет default_format (#43)."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
output = str(tmp_path / "report")
|
output = str(tmp_path / "report")
|
||||||
@@ -128,7 +135,7 @@ def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
|
|||||||
def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
|
def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
|
||||||
"""При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy."""
|
"""При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_gf.return_value = None
|
mock_gf.return_value = None
|
||||||
output = str(tmp_path / "report.odt")
|
output = str(tmp_path / "report.odt")
|
||||||
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
||||||
@@ -198,7 +205,7 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
|
|||||||
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
|
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
|
||||||
"""--summary выводит общее время и разбивку по проектам в stderr."""
|
"""--summary выводит общее время и разбивку по проектам в stderr."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
main(["--date", "2026-01-01--2026-01-31", "--summary"])
|
main(["--date", "2026-01-01--2026-01-31", "--summary"])
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "Total time" in captured.err
|
assert "Total time" in captured.err
|
||||||
@@ -210,7 +217,7 @@ def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
|
|||||||
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
|
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
|
||||||
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
|
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
main(["--date", "2026-01-01--2026-01-31"])
|
main(["--date", "2026-01-01--2026-01-31"])
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "Total issues" not in captured.out
|
assert "Total issues" not in captured.out
|
||||||
@@ -264,7 +271,7 @@ def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
|
|||||||
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
|
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
|
||||||
"""CLI --no-time передаётся в файловый форматтер."""
|
"""CLI --no-time передаётся в файловый форматтер."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
output = str(tmp_path / "report.xlsx")
|
output = str(tmp_path / "report.xlsx")
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
@@ -418,6 +425,49 @@ class TestInitConfig:
|
|||||||
assert data["redmine"]["url"] == ""
|
assert data["redmine"]["url"] == ""
|
||||||
assert data["redmine"]["api_key"] == ""
|
assert data["redmine"]["api_key"] == ""
|
||||||
|
|
||||||
|
def test_init_config_reads_dotenv_file(self, tmp_path, monkeypatch):
|
||||||
|
"""#64: --init-config подхватывает значения из реального .env в cwd.
|
||||||
|
|
||||||
|
Регрессионный тест: после 1df1194 load_dotenv перестал выполняться
|
||||||
|
до _run_init_config, и пользователь с настройками только в .env
|
||||||
|
получал пустой YAML.
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
(tmp_path / ".env").write_text(
|
||||||
|
"REDMINE_URL=https://redmine.example.com\n"
|
||||||
|
"REDMINE_API_KEY=secret-from-dotenv\n"
|
||||||
|
"REDMINE_AUTHOR=ivan.ivanov\n"
|
||||||
|
"DEFAULT_FROM_DATE=2026-01-01\n"
|
||||||
|
"DEFAULT_TO_DATE=2026-01-31\n"
|
||||||
|
"SMTP_PASSWORD=smtp-secret\n"
|
||||||
|
)
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
# Целевые переменные не мокаются: значения должны прийти только из .env.
|
||||||
|
for var in (
|
||||||
|
"REDMINE_URL",
|
||||||
|
"REDMINE_API_KEY",
|
||||||
|
"REDMINE_AUTHOR",
|
||||||
|
"DEFAULT_FROM_DATE",
|
||||||
|
"DEFAULT_TO_DATE",
|
||||||
|
"SMTP_PASSWORD",
|
||||||
|
):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
code = main(["--init-config", "--config-path", str(config_path)])
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
with open(config_path) as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
assert data["redmine"]["url"] == "https://redmine.example.com"
|
||||||
|
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
|
||||||
|
assert data["redmine"]["author"] == "ivan.ivanov"
|
||||||
|
assert data["period"]["default_from"] == "2026-01-01"
|
||||||
|
assert data["period"]["default_to"] == "2026-01-31"
|
||||||
|
assert data["email"]["smtp"]["password"] == "${SMTP_PASSWORD}"
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
def test_init_config_includes_report_section(self, tmp_path):
|
def test_init_config_includes_report_section(self, tmp_path):
|
||||||
"""--init-config генерирует секцию report с no_time: false."""
|
"""--init-config генерирует секцию report с no_time: false."""
|
||||||
@@ -433,6 +483,55 @@ class TestInitConfig:
|
|||||||
assert "report" in data
|
assert "report" in data
|
||||||
assert data["report"]["no_time"] is False
|
assert data["report"]["no_time"] is False
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
def test_init_config_includes_status_translation_example(self, tmp_path):
|
||||||
|
"""#65: --init-config дописывает закомментированный пример status_translation.
|
||||||
|
|
||||||
|
Файл остаётся валидным YAML, активной секции status_translation нет.
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
code = main(["--init-config", "--config-path", str(config_path)])
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
text = config_path.read_text(encoding="utf-8")
|
||||||
|
assert "# status_translation:" in text
|
||||||
|
|
||||||
|
with open(config_path) as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
assert "status_translation" not in data["report"]
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_status_translation_from_yaml_reaches_builder(mock_fetch, tmp_path):
|
||||||
|
"""#65: report.status_translation из YAML доезжает до build_grouped_report."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"report:\n"
|
||||||
|
" status_translation:\n"
|
||||||
|
' New: "Новая"\n'
|
||||||
|
' "Custom Status": "Кастом"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.cli.build_grouped_report") as mock_build:
|
||||||
|
mock_build.return_value = []
|
||||||
|
code = main(
|
||||||
|
["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
_, kwargs = mock_build.call_args
|
||||||
|
translation = kwargs["status_translation"]
|
||||||
|
assert translation["New"] == "Новая"
|
||||||
|
assert translation["Custom Status"] == "Кастом"
|
||||||
|
# встроенные переводы сохранились
|
||||||
|
assert translation["Closed"] == "Закрыто"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, {}, clear=True)
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@@ -495,7 +594,7 @@ class TestOutputPathResolution:
|
|||||||
):
|
):
|
||||||
"""--output report без расширения → добавляет .xlsx."""
|
"""--output report без расширения → добавляет .xlsx."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
output = str(tmp_path / "report")
|
output = str(tmp_path / "report")
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
@@ -521,7 +620,7 @@ class TestOutputPathResolution:
|
|||||||
def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path):
|
def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path):
|
||||||
"""--output report.csv с явным расширением передаётся как есть."""
|
"""--output report.csv с явным расширением передаётся как есть."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -546,7 +645,7 @@ class TestOutputPathResolution:
|
|||||||
def test_output_bare_format_invalid_is_treated_as_path(self, mock_fetch, tmp_path):
|
def test_output_bare_format_invalid_is_treated_as_path(self, mock_fetch, tmp_path):
|
||||||
"""--output notanxlsx (no path, not known format) → treated as path + .xlsx appended."""
|
"""--output notanxlsx (no path, not known format) → treated as path + .xlsx appended."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -583,7 +682,7 @@ class TestCommitFlag:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -620,7 +719,7 @@ class TestCommitFlag:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -649,6 +748,49 @@ class TestCommitFlag:
|
|||||||
assert "T" in saved_from
|
assert "T" in saved_from
|
||||||
assert "T" in saved_to
|
assert "T" in saved_to
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
|
def test_commit_with_precision_datetime_saves_aware_utc(
|
||||||
|
self, mock_save, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
|
"""При precision=datetime --commit сохраняет last_used как aware UTC (#58)."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
|
mock_formatter = mock.MagicMock()
|
||||||
|
mock_get.return_value = mock_formatter
|
||||||
|
code = main(
|
||||||
|
[
|
||||||
|
"--date",
|
||||||
|
"2026-06-30--2026-06-30",
|
||||||
|
"--commit",
|
||||||
|
"--output",
|
||||||
|
str(tmp_path / "report.xlsx"),
|
||||||
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
call_args = mock_save.call_args
|
||||||
|
assert call_args is not None
|
||||||
|
saved_from, saved_to = call_args.args[1], call_args.args[2]
|
||||||
|
for saved in (saved_from, saved_to):
|
||||||
|
parsed = datetime.fromisoformat(saved)
|
||||||
|
assert parsed.tzinfo is not None, f"{saved} must be timezone-aware"
|
||||||
|
assert parsed.utcoffset() == timezone.utc.utcoffset(None)
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
|
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
|
||||||
@@ -679,7 +821,7 @@ class TestCommitFlag:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -716,7 +858,7 @@ class TestCommitFlag:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -757,7 +899,7 @@ class TestCommitFlag:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -825,7 +967,7 @@ class TestSendFlag:
|
|||||||
def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path):
|
def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path):
|
||||||
"""--send с --output вызывает send_report после сохранения."""
|
"""--send с --output вызывает send_report после сохранения."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
@@ -866,7 +1008,7 @@ class TestSendFlag:
|
|||||||
):
|
):
|
||||||
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
@@ -905,7 +1047,7 @@ class TestSendFlag:
|
|||||||
def test_send_without_email_config_is_error(self, mock_fetch, tmp_path, capsys):
|
def test_send_without_email_config_is_error(self, mock_fetch, tmp_path, capsys):
|
||||||
"""--send без email-конфига — ошибка и выход 1."""
|
"""--send без email-конфига — ошибка и выход 1."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text("redmine:\n url: https://x.com\n api_key: token\n")
|
config_path.write_text("redmine:\n url: https://x.com\n api_key: token\n")
|
||||||
@@ -936,7 +1078,7 @@ class TestSendFlag:
|
|||||||
from redmine_reporter.client import RedmineAPIError
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
mock_send.side_effect = RedmineAPIError(
|
mock_send.side_effect = RedmineAPIError(
|
||||||
@@ -981,7 +1123,7 @@ class TestSendFlag:
|
|||||||
):
|
):
|
||||||
"""--send и --commit работают вместе без конфликтов."""
|
"""--send и --commit работают вместе без конфликтов."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
@@ -1038,7 +1180,7 @@ class TestSendHtmlBody:
|
|||||||
):
|
):
|
||||||
"""--send передаёт rows в send_report для генерации HTML."""
|
"""--send передаёт rows в send_report для генерации HTML."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
@@ -1083,7 +1225,7 @@ class TestSendHtmlBody:
|
|||||||
):
|
):
|
||||||
"""При email.html: false письмо отправляется без HTML-части."""
|
"""При email.html: false письмо отправляется без HTML-части."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
@@ -1167,7 +1309,7 @@ class TestReportNoTimeIntegration:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1207,7 +1349,7 @@ class TestReportNoTimeIntegration:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1247,7 +1389,7 @@ class TestReportNoTimeIntegration:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1287,7 +1429,7 @@ class TestReportNoTimeIntegration:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1325,7 +1467,7 @@ class TestReportNoTimeIntegration:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1365,3 +1507,156 @@ class TestReportNoTimeIntegration:
|
|||||||
|
|
||||||
_, kwargs = mock_get.call_args
|
_, kwargs = mock_get.call_args
|
||||||
assert kwargs.get("no_time") is True
|
assert kwargs.get("no_time") is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #59: dynamic + precision=datetime без --date
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
|
||||||
|
"""dynamic+datetime без --date: datetime-диапазон из last_used принимается.
|
||||||
|
|
||||||
|
Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон
|
||||||
|
с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used,
|
||||||
|
отчёт строится без ошибки.
|
||||||
|
"""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"period:\n"
|
||||||
|
" precision: datetime\n"
|
||||||
|
" dynamic: true\n"
|
||||||
|
" last_used:\n"
|
||||||
|
" from: '2026-06-01T00:00:00'\n"
|
||||||
|
" to: '2026-06-30T23:59:59'\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
code = main(["--config-path", str(config_path)])
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
args, _ = mock_fetch.call_args
|
||||||
|
from_date, to_date = args[0], args[1]
|
||||||
|
# Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59
|
||||||
|
assert from_date == "2026-07-01T00:00:00"
|
||||||
|
assert to_date == "2026-07-30T23:59:59"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #55: sanitize Redmine exception output in CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizeErrorOutput:
|
||||||
|
"""CLI не должен выводить API-ключ из текста исключений в stderr (#55)."""
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_api_key_in_url_is_masked(self, mock_fetch, capsys):
|
||||||
|
"""key=SECRET в URL внутри сообщения исключения маскируется."""
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
mock_fetch.side_effect = RedmineAPIError(
|
||||||
|
"Network error while calling Redmine: "
|
||||||
|
"HTTPSConnectionPool(host='red.example.com', port=443): "
|
||||||
|
"Failed https://red.example.com/issues.json?key=SECRET123&limit=100"
|
||||||
|
)
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert code == 1
|
||||||
|
assert "SECRET123" not in captured.err
|
||||||
|
assert "key=***" in captured.err
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_api_key_header_is_masked(self, mock_fetch, capsys):
|
||||||
|
"""Заголовок X-Redmine-API-Key в тексте исключения маскируется."""
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
mock_fetch.side_effect = RedmineAPIError(
|
||||||
|
"Request failed: headers {'X-Redmine-API-Key': 'SECRET123'}"
|
||||||
|
)
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert code == 1
|
||||||
|
assert "SECRET123" not in captured.err
|
||||||
|
assert "X-Redmine-API-Key" in captured.err
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_unexpected_exception_is_masked(self, mock_fetch, capsys):
|
||||||
|
"""Общее исключение с key=SECRET в тексте тоже санитизируется."""
|
||||||
|
mock_fetch.side_effect = RuntimeError(
|
||||||
|
"boom while requesting https://red.example.com/t.json?key=SECRET123"
|
||||||
|
)
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert code == 1
|
||||||
|
assert "SECRET123" not in captured.err
|
||||||
|
assert "Unexpected error" in captured.err
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_error_message_readability_preserved(self, mock_fetch, capsys):
|
||||||
|
"""Санитизация не ломает обычные сообщения об ошибках."""
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert code == 1
|
||||||
|
assert "Authentication failed: bad key" in captured.err
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_debug_shows_full_traceback(self, mock_fetch, caplog):
|
||||||
|
"""--debug выводит полный стектрейс исходного исключения."""
|
||||||
|
import logging as _logging
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
original = ValueError("raw details https://red.example.com?key=SECRET123")
|
||||||
|
error = RedmineAPIError("safe message", original=original)
|
||||||
|
error.__cause__ = original
|
||||||
|
mock_fetch.side_effect = error
|
||||||
|
with caplog.at_level(_logging.ERROR):
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31", "--debug"])
|
||||||
|
assert code == 1
|
||||||
|
assert "raw details" in caplog.text
|
||||||
|
assert "Traceback" in caplog.text
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_verbose_shows_full_traceback(self, mock_fetch, caplog):
|
||||||
|
"""--verbose тоже даёт доступ к деталям/стектрейсу исходной ошибки."""
|
||||||
|
import logging as _logging
|
||||||
|
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
original = ValueError("raw details https://red.example.com?key=SECRET123")
|
||||||
|
error = RedmineAPIError("safe message", original=original)
|
||||||
|
error.__cause__ = original
|
||||||
|
mock_fetch.side_effect = error
|
||||||
|
with caplog.at_level(_logging.INFO):
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31", "--verbose"])
|
||||||
|
assert code == 1
|
||||||
|
assert "raw details" in caplog.text
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_no_flags_hides_original_details(self, mock_fetch, caplog, capsys):
|
||||||
|
"""Без флагов детали исходного исключения не печатаются."""
|
||||||
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
|
original = ValueError("raw details https://red.example.com?key=SECRET123")
|
||||||
|
mock_fetch.side_effect = RedmineAPIError("safe message", original=original)
|
||||||
|
code = main(["--date", "2026-01-01--2026-01-31"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert code == 1
|
||||||
|
assert "raw details" not in captured.err
|
||||||
|
assert "raw details" not in caplog.text
|
||||||
|
assert "safe message" in captured.err
|
||||||
|
|||||||
@@ -19,6 +19,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):
|
||||||
@@ -172,6 +184,70 @@ 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.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.client.Redmine")
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
|
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
|
||||||
@@ -275,6 +351,7 @@ def test_fetch_resolves_user_login_to_id(mock_redmine_class):
|
|||||||
|
|
||||||
mock_user = mock.MagicMock()
|
mock_user = mock.MagicMock()
|
||||||
mock_user.id = 7
|
mock_user.id = 7
|
||||||
|
mock_user.login = "ivanov"
|
||||||
mock_redmine.user.filter.return_value = [mock_user]
|
mock_redmine.user.filter.return_value = [mock_user]
|
||||||
mock_redmine.time_entry.filter.return_value = []
|
mock_redmine.time_entry.filter.return_value = []
|
||||||
|
|
||||||
@@ -287,6 +364,91 @@ def test_fetch_resolves_user_login_to_id(mock_redmine_class):
|
|||||||
assert kwargs["user_id"] == 7
|
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.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.client.Redmine")
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
def test_fetch_resolves_user_name_to_id(mock_redmine_class):
|
def test_fetch_resolves_user_name_to_id(mock_redmine_class):
|
||||||
@@ -691,3 +853,303 @@ def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
|
|||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
ids = {r[0].id for r in result}
|
ids = {r[0].id for r in result}
|
||||||
assert ids == {20, 30}
|
assert ids == {20, 30}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Пагинация 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
|
||||||
|
|
||||||
|
|
||||||
|
# -- #58: дедупликация при precision=datetime с naive created_on --
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class):
|
||||||
|
"""python-redmine отдаёт naive created_on; cutoff aware (UTC) — фильтрация не падает.
|
||||||
|
|
||||||
|
Ожидаемое поведение после фикса #58: naive datetime трактуется как UTC,
|
||||||
|
старые записи отсекаются, новые сохраняются.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
|
||||||
|
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
e_old = mock.MagicMock()
|
||||||
|
e_old.issue.id = 1
|
||||||
|
e_old.hours = 2.0
|
||||||
|
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0) # naive, как из python-redmine
|
||||||
|
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
|
||||||
|
|
||||||
|
e_new = mock.MagicMock()
|
||||||
|
e_new.issue.id = 2
|
||||||
|
e_new.hours = 1.0
|
||||||
|
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0) # naive
|
||||||
|
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
|
||||||
|
|
||||||
|
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
|
||||||
|
|
||||||
|
mock_issue1 = mock.MagicMock()
|
||||||
|
mock_issue1.id = 1
|
||||||
|
mock_issue1.project = "P"
|
||||||
|
mock_issue1.subject = "T"
|
||||||
|
mock_issue1.status = "New"
|
||||||
|
mock_issue2 = mock.MagicMock()
|
||||||
|
mock_issue2.id = 2
|
||||||
|
mock_issue2.project = "P"
|
||||||
|
mock_issue2.subject = "T"
|
||||||
|
mock_issue2.status = "New"
|
||||||
|
# API возвращает обе задачи: issue 1 попадёт в результат, только если
|
||||||
|
# её time entry не отсечён дедупликацией. Корректный фикс #58 отсекает
|
||||||
|
# e_old (created_on/updated_on < cutoff) -> [2]; неправильный фикс
|
||||||
|
# "погасить TypeError и оставить всё" даст [1, 2] и тест упадёт.
|
||||||
|
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time(
|
||||||
|
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert [r[0].id for r in result] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
# -- #58: нормализация datetime к aware UTC --
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseDatetimeNormalization:
|
||||||
|
"""_parse_datetime всегда возвращает aware datetime; naive трактуется как UTC."""
|
||||||
|
|
||||||
|
def test_naive_datetime_treated_as_utc(self):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from redmine_reporter.client import _parse_datetime
|
||||||
|
|
||||||
|
result = _parse_datetime(datetime(2026, 7, 1, 10, 0, 0))
|
||||||
|
assert result == datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
def test_aware_datetime_kept(self):
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from redmine_reporter.client import _parse_datetime
|
||||||
|
|
||||||
|
aware = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone(timedelta(hours=3)))
|
||||||
|
assert _parse_datetime(aware) is aware
|
||||||
|
|
||||||
|
def test_naive_iso_string_treated_as_utc(self):
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
|
from redmine_reporter.client import _parse_datetime
|
||||||
|
|
||||||
|
result = _parse_datetime("2026-07-01T10:00:00")
|
||||||
|
assert result is not None
|
||||||
|
assert result.tzinfo == timezone.utc
|
||||||
|
|
||||||
|
def test_aware_iso_string_keeps_offset(self):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from redmine_reporter.client import _parse_datetime
|
||||||
|
|
||||||
|
result = _parse_datetime("2026-07-01T10:00:00+03:00")
|
||||||
|
assert result is not None
|
||||||
|
assert result.utcoffset() == timedelta(hours=3)
|
||||||
|
|
||||||
|
def test_none_and_garbage_return_none(self):
|
||||||
|
from redmine_reporter.client import _parse_datetime
|
||||||
|
|
||||||
|
assert _parse_datetime(None) is None
|
||||||
|
assert _parse_datetime("not a date") is None
|
||||||
|
assert _parse_datetime(123) is None
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_dedup_accepts_naive_cutoff(mock_redmine_class):
|
||||||
|
"""Naive dedup_before трактуется как UTC — сравнение не падает (#58)."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
|
||||||
|
naive_cutoff = datetime(2026, 7, 1, 12, 0, 0) # naive == UTC
|
||||||
|
|
||||||
|
e_old = mock.MagicMock()
|
||||||
|
e_old.issue.id = 1
|
||||||
|
e_old.hours = 2.0
|
||||||
|
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0)
|
||||||
|
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
|
||||||
|
|
||||||
|
e_new = mock.MagicMock()
|
||||||
|
e_new.issue.id = 2
|
||||||
|
e_new.hours = 1.0
|
||||||
|
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0)
|
||||||
|
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
|
||||||
|
|
||||||
|
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
|
||||||
|
|
||||||
|
mock_issue1 = mock.MagicMock()
|
||||||
|
mock_issue1.id = 1
|
||||||
|
mock_issue2 = mock.MagicMock()
|
||||||
|
mock_issue2.id = 2
|
||||||
|
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time(
|
||||||
|
"2026-07-01", "2026-07-01", dedup_before=naive_cutoff
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert [r[0].id for r in result] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
# -- #61: предупреждения о молчаливой потере данных --
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|||||||
@@ -5,12 +5,7 @@ from unittest import mock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from redmine_reporter.config import (
|
from redmine_reporter.config import AppConfig, Config, EmailConfig
|
||||||
DEFAULT_REDMINE_VERIFY,
|
|
||||||
AppConfig,
|
|
||||||
Config,
|
|
||||||
EmailConfig,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(
|
@mock.patch.dict(
|
||||||
@@ -79,26 +74,89 @@ def test_get_default_date_range_from_env():
|
|||||||
assert Config.get_default_date_range() == "2026-01-01--2026-01-31"
|
assert Config.get_default_date_range() == "2026-01-01--2026-01-31"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"DEFAULT_FROM_DATE": "2026-01-01"},
|
||||||
|
clear=True,
|
||||||
|
)
|
||||||
|
def test_get_default_date_range_from_env_without_to():
|
||||||
|
"""Если DEFAULT_TO_DATE не задан, конец периода — сегодня."""
|
||||||
|
from 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_default_from_falls_back_to_today(self):
|
||||||
|
"""default_from задан, default_to нет → конец периода = сегодня."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("period:\n default_from: '2026-04-01'\n")
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||||
|
mock_date.today.return_value = date(2026, 4, 20)
|
||||||
|
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||||||
|
assert Config.get_default_date_range() == "2026-04-01--2026-04-20"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"DEFAULT_FROM_DATE": "2026-02-01"}, clear=True)
|
||||||
|
def test_env_from_without_to_falls_back_to_today(self):
|
||||||
|
"""DEFAULT_FROM_DATE задана, DEFAULT_TO_DATE нет → конец = сегодня."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
Config._app = None
|
||||||
|
with mock.patch("redmine_reporter.config.date") as mock_date:
|
||||||
|
mock_date.today.return_value = date(2026, 2, 10)
|
||||||
|
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
||||||
|
assert Config.get_default_date_range() == "2026-02-01--2026-02-10"
|
||||||
|
|
||||||
|
|
||||||
@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"])
|
||||||
@@ -118,17 +176,103 @@ 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 = {"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)
|
||||||
|
|
||||||
@@ -257,16 +401,57 @@ class TestAppConfigFromYaml:
|
|||||||
|
|
||||||
assert cfg.redmine_api_key == "secret-token"
|
assert cfg.redmine_api_key == "secret-token"
|
||||||
|
|
||||||
def test_verify_ssl_true_returns_default_ca_path(self):
|
def test_verify_ssl_true_returns_true(self):
|
||||||
"""verify_ssl: true → DEFAULT_REDMINE_VERIFY (путь), не Python True."""
|
"""verify_ssl: true → bool True (стандартная проверка TLS)."""
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
yaml_path = Path(tmp) / "config.yml"
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
yaml_path.write_text("redmine:\n verify_ssl: true\n")
|
yaml_path.write_text("redmine:\n verify_ssl: true\n")
|
||||||
|
|
||||||
cfg = AppConfig.from_yaml(yaml_path)
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
assert cfg.redmine_verify == DEFAULT_REDMINE_VERIFY
|
assert cfg.redmine_verify is True
|
||||||
assert cfg.redmine_verify is not 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):
|
def test_verify_ssl_false_returns_false(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
@@ -567,6 +752,24 @@ class TestDefaultDateRangeWithLastUsed:
|
|||||||
result = Config.get_default_date_range()
|
result = Config.get_default_date_range()
|
||||||
assert result == "2026-04-01--2026-04-15"
|
assert result == "2026-04-01--2026-04-15"
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_falls_back_to_today_when_default_to_missing(self):
|
||||||
|
"""Если default_from задан, а default_to нет — конец периода сегодня."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"period:\n"
|
||||||
|
" precision: date\n"
|
||||||
|
" dynamic: false\n"
|
||||||
|
" default_from: '2026-04-01'\n"
|
||||||
|
)
|
||||||
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
|
today = date.today().isoformat()
|
||||||
|
result = Config.get_default_date_range()
|
||||||
|
assert result == f"2026-04-01--{today}"
|
||||||
|
|
||||||
|
|
||||||
class TestGetEmailConfig:
|
class TestGetEmailConfig:
|
||||||
"""Tests for Config.get_email_config()."""
|
"""Tests for Config.get_email_config()."""
|
||||||
@@ -704,3 +907,169 @@ class TestReportNoTime:
|
|||||||
|
|
||||||
Config._app = AppConfig.from_yaml(yaml_path)
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
assert Config.get_report_no_time() is False
|
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
|
||||||
|
|||||||
@@ -396,6 +396,32 @@ def test_xlsx_has_totals(fake_rows, tmp_path):
|
|||||||
assert ws["F2"].number_format == "0.00"
|
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):
|
def test_xlsx_no_time_keeps_columns_empty_and_skips_totals(fake_rows, tmp_path):
|
||||||
"""XLSX с no_time: колонки времени пустые, итогов нет."""
|
"""XLSX с no_time: колонки времени пустые, итогов нет."""
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
@@ -456,6 +482,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 форматтера --
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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"
|
||||||
@@ -49,7 +49,37 @@ class TestBuildMessage:
|
|||||||
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", [])
|
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", [])
|
||||||
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||||
assert len(plain_parts) == 1
|
assert len(plain_parts) == 1
|
||||||
assert "Автор: Иванов, период: Q1" in plain_parts[0].as_string()
|
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("utf-8") 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 = [p for p in msg.walk() if p.get_content_type() == "text/plain"][0]
|
||||||
|
assert plain["Content-Transfer-Encoding"] == "8bit"
|
||||||
|
assert (
|
||||||
|
plain.get_payload(decode=True).decode("utf-8")
|
||||||
|
== "Автор: Иванов, период: Q1"
|
||||||
|
)
|
||||||
|
|
||||||
def test_from_header(self):
|
def test_from_header(self):
|
||||||
cfg = _make_email_config(from_="sender@example.com", attach=False)
|
cfg = _make_email_config(from_="sender@example.com", attach=False)
|
||||||
|
|||||||
@@ -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"] == "Закрыто"
|
||||||
|
|||||||
Reference in New Issue
Block a user