Compare commits
101 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 | ||
|
|
e1862462af | ||
|
|
863ad50cc3 | ||
|
|
5e1c366a60 | ||
|
|
0968560090 | ||
|
|
25425901b1 | ||
|
|
b0e353c565 | ||
|
|
b926dd0d49 | ||
|
|
80faccb1f9 | ||
|
|
9b78d66769 | ||
|
|
485be063d2 | ||
|
|
47152f8f04 | ||
|
|
67350bfcd6 | ||
|
|
0fa4e271a7 | ||
|
|
b1a565bc9e | ||
|
|
c962a93f30 | ||
|
|
676f7ede30 | ||
|
|
5dd234e7b3 | ||
|
|
59af7ce464 | ||
|
|
f6861382e6 | ||
|
|
67b5d093d9 | ||
|
|
f80f3a8b52 | ||
|
|
222d31730e | ||
|
|
86f083aa79 | ||
|
|
a82be05b83 | ||
|
|
ca89832d74 | ||
|
|
f6afc4096d | ||
|
|
738d9d543e | ||
|
|
58fa5a7ab4 | ||
|
|
14219564dd | ||
|
|
da069993b9 | ||
|
|
dbc4cf960a | ||
|
|
3a6d1b7ba7 | ||
|
|
3956decd4e | ||
|
|
0e4e0f3ee2 | ||
|
|
2db0ab1f0b | ||
|
|
8bc8181ce3 | ||
|
|
7bc6e024c0 | ||
|
|
06cd57e2c4 | ||
|
|
d7e927e6eb | ||
|
|
000bf37503 | ||
|
|
dfb8d474b4 | ||
|
|
b7f03666dc | ||
|
|
30310d614d | ||
|
|
ad62ef4f6c | ||
|
|
355849e004 | ||
|
|
d839be8776 | ||
|
|
ea90fe79d0 | ||
|
|
1f77088c21 | ||
|
|
8278864b01 | ||
|
|
f858618a13 | ||
|
|
e344715f61 | ||
|
|
245ea0a3fa | ||
|
|
2a39de467f | ||
|
|
6416df481e | ||
|
|
ead6c72d16 | ||
|
|
e7efda232c | ||
|
|
937885a12b | ||
|
|
932dd1198a | ||
|
|
0bff2363dc | ||
|
|
9b260b27fd | ||
|
|
a8511368ce | ||
|
|
7a8b629c7c | ||
|
|
41c7ef24a3 | ||
|
|
5b813c76e9 | ||
|
|
5a5ee00726 | ||
|
|
4a5dee7a14 | ||
|
|
2c123e9ae7 | ||
|
|
9a2f753480 | ||
|
|
bca24189c7 | ||
|
|
6fcc834617 |
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
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -85,3 +85,11 @@ secrets.json
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
docs/superpowers
|
||||
|
||||
# Just in case
|
||||
.~*
|
||||
# Local report outputs
|
||||
report.*
|
||||
rep.*
|
||||
*.html
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Artem Kokos
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
190
README.md
190
README.md
@@ -1,132 +1,106 @@
|
||||
# redmine-reporter
|
||||
|
||||
Инструмент для генерации отчётов по задачам в Redmine на основе ваших записей о затраченном времени.
|
||||
[](https://git.akokos.ru/artem.kokos/redmine-reporter/actions)
|
||||
|
||||
> Предназначен для внутреннего использования в Eltex. Работает с `https://red.eltex.loc/`.
|
||||
- [Возможности](#возможности)
|
||||
- [Установка](#установка)
|
||||
- [Быстрый старт](#быстрый-старт)
|
||||
- [Документация](#документация)
|
||||
- [Форматы вывода](#форматы-вывода)
|
||||
- [Разработка](#разработка)
|
||||
- [Безопасность](#безопасность)
|
||||
- [Лицензия](#лицензия)
|
||||
|
||||
---
|
||||
CLI-инструмент для генерации отчётов по задачам Redmine на основе записей о затраченном времени. Читает time entries текущего или указанного пользователя, группирует задачи по проекту и версии, выводит отчёт в консоль или экспортирует в файл. Предназначен для внутреннего использования с `https://red.eltex.loc/`.
|
||||
|
||||
## 🔧 Возможности
|
||||
## Возможности
|
||||
|
||||
- Безопасная передача учётных данных через переменные окружения или `.env`
|
||||
- Два режима вывода: компактный (для копирования) и табличный (для просмотра)
|
||||
- Группировка задач по проекту и версии
|
||||
- Перевод статусов на русский язык
|
||||
- Простой CLI с понятными аргументами
|
||||
- Поддержка настройки диапазона дат по умолчанию через `.env`
|
||||
- Отчёт по time entries текущего или указанного пользователя (`--user-id`, `--user-login`, `--user-name`).
|
||||
- Группировка задач по проекту и версии, перевод статусов на русский язык.
|
||||
- Вывод в консоль (таблица или компактный вид) и экспорт в ODT, CSV, Markdown, HTML, JSON, XLSX.
|
||||
- Разбивка времени по типам активности (`--by-activity`), сводка (`--summary`), скрытие времени (`--no-time`).
|
||||
- Гибкий выбор периода: `--date`, переменные окружения, YAML-конфиг, по умолчанию — текущий месяц.
|
||||
- `--commit`: сохранение отчёта в файл и фиксация периода в конфиге для следующего запуска.
|
||||
- `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо).
|
||||
- YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md).
|
||||
|
||||
---
|
||||
## Установка
|
||||
|
||||
## 🚀 Установка и настройка (продакшен)
|
||||
|
||||
### 1. Клонируйте репозиторий
|
||||
Требуется Python >= 3.10.
|
||||
|
||||
```bash
|
||||
git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git
|
||||
cd redmine-reporter
|
||||
```
|
||||
|
||||
### 2. Создайте изолированное окружение и установите зависимости
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install .
|
||||
```
|
||||
|
||||
> 💡 Установка в виртуальное окружение — стандарт для Python-инструментов. Это безопасно и не влияет на систему.
|
||||
|
||||
### 3. Настройте доверие к корпоративному сертификату (обязательно!)
|
||||
|
||||
По умолчанию Python использует собственный набор сертификатов (`certifi`), который **не включает** внутренние CA Eltex.
|
||||
Чтобы избежать ошибки `CERTIFICATE_VERIFY_FAILED`, выполните **один раз**:
|
||||
|
||||
```bash
|
||||
cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi)
|
||||
```
|
||||
|
||||
> ✅ Это безопасно: вы просто добавляете доверенные системные сертификаты к Python.
|
||||
> ❌ Не используйте `verify=False` — это создаёт уязвимость.
|
||||
|
||||
### 4. Настройте учётные данные и (опционально) даты
|
||||
|
||||
Создайте файл `.env` в корне проекта (**никогда не коммитьте его!**):
|
||||
|
||||
```ini
|
||||
REDMINE_URL=https://red.eltex.loc/
|
||||
REDMINE_USER=ваш.логин
|
||||
REDMINE_PASSWORD=ваш_пароль
|
||||
|
||||
# Опционально: диапазон дат по умолчанию
|
||||
DEFAULT_FROM_DATE=2026-01-01
|
||||
DEFAULT_TO_DATE=2026-01-31
|
||||
```
|
||||
|
||||
Альтернатива — задать переменные вручную:
|
||||
|
||||
```bash
|
||||
export REDMINE_URL=https://red.eltex.loc/
|
||||
export REDMINE_USER=ваш.логин
|
||||
export REDMINE_PASSWORD=...
|
||||
export DEFAULT_FROM_DATE=2026-01-01
|
||||
export DEFAULT_TO_DATE=2026-01-31
|
||||
```
|
||||
|
||||
> 🔐 Рекомендуется использовать аккаунт с минимальными правами (только чтение time entries и задач).
|
||||
> 💡 Если `DEFAULT_FROM_DATE` и `DEFAULT_TO_DATE` не заданы, используется встроенный диапазон: `2025-12-19--2026-01-31`.
|
||||
|
||||
---
|
||||
|
||||
## ▶️ Использование
|
||||
|
||||
Перед каждым запуском активируйте окружение:
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
Затем:
|
||||
|
||||
```bash
|
||||
# Отчёт за период по умолчанию (из .env или встроенный)
|
||||
redmine-reporter
|
||||
|
||||
# Отчёт за произвольный период (переопределяет .env)
|
||||
redmine-reporter --date 2026-02-01--2026-02-28
|
||||
|
||||
# Компактный вывод (удобно копировать в письмо)
|
||||
redmine-reporter --compact
|
||||
```
|
||||
|
||||
Пример вывода:
|
||||
```
|
||||
✅ Total issues: 7 [2026-01-01--2026-01-31]
|
||||
╒════════════╤═══════════╤══════════════════════════════════════╤═══════════╤════════════╕
|
||||
│ Проект │ Версия │ Задача │ Статус │ Затрачено │
|
||||
╞════════════╪═══════════╪══════════════════════════════════════╪═══════════╪════════════╡
|
||||
│ Камеры │ v2.5.0 │ 12345. Поддержка нового датчика │ В работе │ 2.00h │
|
||||
│ │ │ 12346. Исправить утечку памяти │ Решена │ 2.00h │
|
||||
│ ПО │ <N/A> │ 12350. Обновить документацию │ Ожидание │ 12.00h │
|
||||
╘════════════╧═══════════╧══════════════════════════════════════╧═══════════╧════════════╛
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Разработка
|
||||
|
||||
Для участия в разработке:
|
||||
Для разработки:
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
black .
|
||||
isort .
|
||||
```
|
||||
|
||||
---
|
||||
## Быстрый старт
|
||||
|
||||
> 🔒 **Важно**:
|
||||
> - Никогда не коммитьте `.env`, пароли или логины.
|
||||
> - Файл `.gitignore` уже исключает все чувствительные артефакты.
|
||||
> - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine.
|
||||
```bash
|
||||
# Сгенерировать конфиг ~/.config/redmine-reporter/config.yml
|
||||
redmine-reporter --init-config
|
||||
|
||||
# Заполнить redmine.url, redmine.api_key (или ${REDMINE_API_KEY}), redmine.author
|
||||
vim ~/.config/redmine-reporter/config.yml
|
||||
|
||||
# Отчёт за текущий месяц в консоль
|
||||
redmine-reporter
|
||||
|
||||
# Сохранить в файл и зафиксировать период для следующего запуска
|
||||
redmine-reporter --commit
|
||||
```
|
||||
|
||||
## Документация
|
||||
|
||||
- [docs/USER_GUIDE.md](docs/USER_GUIDE.md) — руководство пользователя: сценарии использования, справочник всех CLI-флагов, устранение неполадок.
|
||||
- [docs/CONFIG.md](docs/CONFIG.md) — справочник конфигурации: YAML-структура, переменные окружения, приоритеты, безопасность.
|
||||
|
||||
## Форматы вывода
|
||||
|
||||
| Формат | Особенности |
|
||||
| --- | --- |
|
||||
| Консоль | Таблица или компактный вид (`--compact`). |
|
||||
| ODT | Требуется `odfpy`; формирование по шаблону. |
|
||||
| CSV | UTF-8 с BOM; полные значения `project`/`version` в каждой строке. |
|
||||
| Markdown | Компактная таблица. |
|
||||
| HTML | Полный HTML-документ; объединение ячеек групп через rowspan. |
|
||||
| JSON | Объекты `project`, `version`, `issue_id`, `subject`, `status`, `time` + опционально `activities`. |
|
||||
| XLSX | Объединение ячеек по проекту/версии, итоги, автоширина (максимум 80), автофильтр, freeze panes. |
|
||||
|
||||
Нюанс `--no-time`: физически удаляет колонку времени только CSV; в XLSX колонки остаются, но пустыми и без итогов; в остальных форматах — пустые значения.
|
||||
|
||||
## Разработка
|
||||
|
||||
Проверки перед коммитом:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
isort --check-only redmine_reporter tests
|
||||
black --check redmine_reporter tests
|
||||
ruff check redmine_reporter tests
|
||||
ruff format --check redmine_reporter tests
|
||||
mypy redmine_reporter
|
||||
```
|
||||
|
||||
CI — Gitea Actions (`.gitea/workflows/checks.yaml`): все шесть проверок на матрице Python 3.10–3.13.
|
||||
|
||||
## Безопасность
|
||||
|
||||
- `REDMINE_URL` обязан использовать HTTPS: валидация отклоняет остальное, API-ключ передаётся в заголовках.
|
||||
- `verify_ssl` / `REDMINE_VERIFY`: `true` (по умолчанию — проверка по системному хранилищу CA операционной системы через truststore, корпоративные CA из ОС работают), `false` (предупреждение о MITM-риске при старте) или путь к CA-bundle.
|
||||
- Конфиг создаётся с правами `0600`, директория — `0700`; при более широких правах выводится предупреждение.
|
||||
- Секреты храните через `${VAR}` в YAML или в переменных окружения, не в открытом виде.
|
||||
- Инструмент только читает данные из Redmine и ничего в нём не изменяет.
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT, см. [LICENSE](LICENSE).
|
||||
|
||||
529
docs/CONFIG.md
Normal file
529
docs/CONFIG.md
Normal file
@@ -0,0 +1,529 @@
|
||||
# Настройка 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)
|
||||
- [Безопасность](#безопасность)
|
||||
|
||||
## Источники конфигурации
|
||||
|
||||
Приоритет, от высшего к низшему:
|
||||
|
||||
```
|
||||
CLI-флаги > переменные окружения > .env > YAML > кодовые дефолты
|
||||
```
|
||||
|
||||
Нюанс с `override` при автозагрузке `.env` и `--config` — см.
|
||||
[Сосуществование `.env` и YAML](#сосуществование-env-и-yaml).
|
||||
|
||||
Если значение не задано на верхнем уровне, берётся уровень ниже. `.env` и YAML
|
||||
работают одновременно — можно оставить оба, можно удалить `.env` после миграции.
|
||||
|
||||
## YAML-конфиг
|
||||
|
||||
Основной файл: `~/.config/redmine-reporter/config.yml`.
|
||||
|
||||
Создаётся с правами `0600` (владелец: чтение/запись, остальные: доступ запрещён).
|
||||
Директория `~/.config/redmine-reporter/` — с правами `0700`.
|
||||
|
||||
Если права файла шире `0600`, при запуске выводится предупреждение.
|
||||
|
||||
### Структура
|
||||
|
||||
```yaml
|
||||
redmine:
|
||||
url: https://red.eltex.loc
|
||||
api_key: ${REDMINE_API_KEY}
|
||||
author: "Кокос А.А."
|
||||
verify_ssl: true
|
||||
|
||||
period:
|
||||
precision: date
|
||||
default_from: "2026-06-01"
|
||||
# default_to можно не указывать — тогда конец периода будет сегодня
|
||||
default_to: "2026-06-30"
|
||||
dynamic: false
|
||||
last_used:
|
||||
from: "2026-06-30T09:00:00"
|
||||
to: "2026-06-30T12:00:00"
|
||||
|
||||
output:
|
||||
dir: ~/reports
|
||||
filename: "{author}_{from}_{to}.{ext}"
|
||||
default_format: xlsx
|
||||
|
||||
report:
|
||||
no_time: false
|
||||
|
||||
email:
|
||||
html: false
|
||||
smtp:
|
||||
host: smtp.example.com
|
||||
port: 587
|
||||
user: bot@example.com
|
||||
password: ${SMTP_PASSWORD}
|
||||
tls: true
|
||||
from: bot@example.com
|
||||
to:
|
||||
- boss@example.com
|
||||
cc: []
|
||||
bcc: []
|
||||
subject: "Отчёт {author} за {period}"
|
||||
body_text: "Во вложении отчёт."
|
||||
attach: true
|
||||
```
|
||||
|
||||
### `redmine.verify_ssl` — проверка TLS-сертификата
|
||||
|
||||
Управляет проверкой TLS-сертификата Redmine. Семантика едина с переменной
|
||||
окружения `REDMINE_VERIFY`:
|
||||
|
||||
| Значение | Поведение |
|
||||
|---|---|
|
||||
| `true` (по умолчанию) | Проверка по системному хранилищу CA операционной системы (через truststore) — корпоративные CA, добавленные в ОС, работают без настройки |
|
||||
| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM |
|
||||
| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть |
|
||||
|
||||
До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь
|
||||
`/etc/ssl/certs/ca-certificates.crt`, который существует только в
|
||||
Debian/Ubuntu. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
|
||||
одинаково — оба включают проверку по системному хранилищу CA на любой ОС
|
||||
(через truststore), без привязки к конкретному пути.
|
||||
|
||||
### `period.precision` — точность периода
|
||||
|
||||
Определяет, как вычисляется следующий период после фиксации:
|
||||
|
||||
- `date` (по умолчанию) — период с точностью до дня. Следующий запуск (после `--commit`) начинается со следующего дня.
|
||||
- `datetime` — период с точностью до секунды. При повторном запуске time entries, у которых `created_on` или `updated_on` раньше `last_used.to`, исключаются: запись сохраняется, только если **оба** поля не раньше cutoff (AND-логика). Записи без дат (оба поля отсутствуют) сохраняются; если задано только одно поле, проверяется оно. Это предотвращает дублирование при отправке отчёта внутри рабочего дня. Cutoff применяется всегда, когда вычислен (`precision: datetime` и задан `last_used.to`) — в том числе при явном `--date`.
|
||||
|
||||
`last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется.
|
||||
|
||||
### `period.default_to` — необязательное окончание периода
|
||||
|
||||
Если `period.default_to` не задан, а `period.default_from` задан, инструмент
|
||||
использует сегодняшнюю дату в качестве конца периода.
|
||||
|
||||
```yaml
|
||||
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` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода.
|
||||
|
||||
**Что делает:**
|
||||
|
||||
1. Генерирует отчёт как обычно.
|
||||
2. Сохраняет отчёт в файл:
|
||||
- Если указан `--output` — по явному пути.
|
||||
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
|
||||
3. Записывает `period.last_used.from` / `period.last_used.to` в YAML-конфиг.
|
||||
4. При `period.precision: datetime` сохраняет текущий момент времени (ISO с секундами).
|
||||
5. При `period.precision: date` сохраняет даты периода.
|
||||
|
||||
**Поведение `period.dynamic`:**
|
||||
|
||||
- `dynamic: true` — следующий запуск (без `--date`) вычисляет период от `last_used`:
|
||||
- Полный календарный месяц → следующий полный месяц.
|
||||
- Произвольный диапазон → та же длительность, начиная со дня после `last_used.to`.
|
||||
- `dynamic: false` — `--commit` перезаписывает `default_from`/`default_to` на использованный период.
|
||||
|
||||
**Нюанс:** при `--commit` YAML-файл перезаписывается целиком через `yaml.dump` —
|
||||
пользовательские комментарии и ручное форматирование в файле теряются.
|
||||
|
||||
**Примеры:**
|
||||
|
||||
```bash
|
||||
# Июнь 2026 → следующий запуск (без --date) → июль 2026
|
||||
redmine-reporter --date 2026-06-01--2026-06-30 --commit
|
||||
|
||||
# Произвольный диапазон: 15-20 июня → следующий запуск → 21-26 июня
|
||||
redmine-reporter --date 2026-06-15--2026-06-20 --commit
|
||||
|
||||
# С datetime-точностью: повторный запуск не дублирует записи
|
||||
redmine-reporter --commit
|
||||
```
|
||||
|
||||
### `email` — настройка отправки по почте
|
||||
|
||||
Секция `email` используется флагом `--send`. Если секция не настроена или `smtp.host`
|
||||
пуст, `--send` завершится с ошибкой «Email не настроен».
|
||||
|
||||
**Все поля:**
|
||||
|
||||
| Поле | Тип | По умолчанию | Описание |
|
||||
|---|---|---|---|
|
||||
| `smtp.host` | строка | `""` | Адрес SMTP-сервера |
|
||||
| `smtp.port` | число | `587` | Порт SMTP |
|
||||
| `smtp.user` | строка | `""` | Логин для аутентификации |
|
||||
| `smtp.password` | строка | `""` | Пароль (рекомендуется `${SMTP_PASSWORD}`) |
|
||||
| `smtp.tls` | bool | `true` | Использовать STARTTLS |
|
||||
| `from` | строка | `""` | Адрес отправителя |
|
||||
| `to` | список | `[]` | Основные получатели |
|
||||
| `cc` | список | `[]` | Копия |
|
||||
| `bcc` | список | `[]` | Скрытая копия (не отображается в заголовках письма) |
|
||||
| `subject` | строка | `"Отчёт {author} за {period}"` | Тема письма |
|
||||
| `body_text` | строка | `"Во вложении отчёт."` | Текст письма (plain text) |
|
||||
| `attach` | bool | `true` | Прикреплять файл отчёта. Если `false` — только текст |
|
||||
| `html` | bool | `false` | Добавить HTML-версию тела письма (`multipart/alternative`) |
|
||||
|
||||
**Подстановки в `subject` и `body_text`:**
|
||||
|
||||
| Плейсхолдер | Описание | Пример |
|
||||
|---|---|---|
|
||||
| `{author}` | Имя автора из конфига или `--author` | `Кокос А.А.` |
|
||||
| `{period}` | Строка диапазона дат | `2026-06-01--2026-06-30` |
|
||||
|
||||
**MIME-тип вложения** определяется по расширению файла:
|
||||
|
||||
| Расширение | MIME-тип |
|
||||
|---|---|
|
||||
| `.xlsx` | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` |
|
||||
| `.odt` | `application/vnd.oasis.opendocument.text` |
|
||||
| `.csv` | `text/csv` |
|
||||
| `.html` | `text/html` |
|
||||
| `.json` | `application/json` |
|
||||
| `.md` | `text/markdown` |
|
||||
|
||||
Неизвестное расширение → `application/octet-stream`.
|
||||
|
||||
**Пример конфигурации:**
|
||||
|
||||
```yaml
|
||||
email:
|
||||
html: false
|
||||
smtp:
|
||||
host: smtp.example.com
|
||||
port: 587
|
||||
user: bot@example.com
|
||||
password: ${SMTP_PASSWORD}
|
||||
tls: true
|
||||
from: bot@example.com
|
||||
to:
|
||||
- boss@example.com
|
||||
- team-lead@example.com
|
||||
cc:
|
||||
- manager@example.com
|
||||
bcc: []
|
||||
subject: "Отчёт {author} за {period}"
|
||||
body_text: "Во вложении отчёт за период {period}."
|
||||
attach: true
|
||||
```
|
||||
|
||||
### `--send` — отправка отчёта по email
|
||||
|
||||
Флаг `--send` отправляет сгенерированный отчёт через SMTP сразу после сохранения
|
||||
в файл. Требует настроенную секцию `email` в YAML-конфиге.
|
||||
|
||||
**Что делает:**
|
||||
|
||||
1. Генерирует отчёт как обычно.
|
||||
2. Сохраняет отчёт в файл:
|
||||
- Если указан `--output` — по явному пути.
|
||||
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
|
||||
3. Формирует MIME-письмо:
|
||||
- Тема, plain-text тело и вложение (если `attach: true`).
|
||||
- При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта.
|
||||
4. Отправляет через SMTP (таймаут 30 секунд). STARTTLS включается только при
|
||||
`email.smtp.tls: true`; аутентификация — только если задан `email.smtp.user`.
|
||||
|
||||
**Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся
|
||||
на диске, данные не теряются.
|
||||
|
||||
**Ошибки SMTP:**
|
||||
|
||||
- Нет соединения → `"Не удалось подключиться к SMTP-серверу host:port"`
|
||||
- Неверный логин/пароль → `"Ошибка аутентификации SMTP. Проверьте логин и пароль."`
|
||||
- Таймаут → `"Таймаут соединения с SMTP-сервером."`
|
||||
- Другая ошибка → `"Ошибка отправки письма: <детали>"`
|
||||
|
||||
Все ошибки выводятся в stderr, код возврата 1.
|
||||
|
||||
**Примеры:**
|
||||
|
||||
```bash
|
||||
# Отправить отчёт за июнь (сохранится по шаблону output.filename)
|
||||
redmine-reporter --date 2026-06-01--2026-06-30 --send
|
||||
|
||||
# С явным путём
|
||||
redmine-reporter --date 2026-06-01--2026-06-30 --output ~/report.xlsx --send
|
||||
|
||||
# Отправить и зафиксировать период
|
||||
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
|
||||
```
|
||||
|
||||
**Совместимость с другими флагами:**
|
||||
|
||||
| Комбинация | Поведение |
|
||||
|---|---|
|
||||
| `--send` | Сохранить по шаблону → отправить |
|
||||
| `--send --output X` | Сохранить в X → отправить |
|
||||
| `--send --commit` | Сохранить → отправить → зафиксировать период |
|
||||
| `--send` с `email.html: true` | Письмо с plain-text + HTML-таблицей |
|
||||
| `--send` без `email` в конфиге | Ошибка, exit 1 |
|
||||
| `--send` при ошибке SMTP | Файл сохранён, ошибка в stderr, exit 1 |
|
||||
|
||||
### `output` — путь и имя файла по умолчанию
|
||||
|
||||
Секция управляет тем, куда и с каким именем сохраняется отчёт, когда `--output` не содержит полного пути.
|
||||
|
||||
**Правила разрешения `--output`:**
|
||||
|
||||
| Аргумент `--output` | Поведение |
|
||||
|---|---|
|
||||
| `/полный/путь/report.xlsx` | Используется как есть, конфиг игнорируется |
|
||||
| `xlsx` (bare format: `xlsx`, `odt`, `csv`, `md`, `html`, `json`) | Путь = `output.dir` + `output.filename`, расширение = bare format |
|
||||
| `/tmp/report` (путь без расширения) | Дописывается `.default_format` → `/tmp/report.xlsx` |
|
||||
|
||||
**Шаблон имени файла:**
|
||||
|
||||
`output.filename` поддерживает подстановки:
|
||||
|
||||
| Плейсхолдер | Описание | Пример |
|
||||
|---|---|---|
|
||||
| `{author}` | Имя автора (пробелы → `_`) | `Кокос_А.А.` |
|
||||
| `{from}` | Начало периода, `YYYY-MM-DD` | `2026-06-01` |
|
||||
| `{to}` | Конец периода, `YYYY-MM-DD` | `2026-06-30` |
|
||||
| `{date}` | Конец периода, `DD_MM_YYYY` | `30_06_2026` |
|
||||
| `{ext}` | Расширение файла без точки | `xlsx` |
|
||||
|
||||
Неизвестные плейсхолдеры остаются в имени как есть.
|
||||
|
||||
Примеры:
|
||||
|
||||
```yaml
|
||||
# По умолчанию
|
||||
filename: "{author}_{from}_{to}.{ext}" # → Кокос_А.А._2026-06-01_2026-06-30.xlsx
|
||||
|
||||
# Русский формат даты
|
||||
filename: "отчёт_{date}.{ext}" # → отчёт_30_06_2026.xlsx
|
||||
|
||||
# Без автора, только диапазон
|
||||
filename: "report_{from}--{to}.{ext}" # → report_2026-06-01--2026-06-30.xlsx
|
||||
```
|
||||
|
||||
### Подстановка переменных окружения
|
||||
|
||||
В любом строковом значении YAML можно использовать `${VAR}` — при загрузке
|
||||
оно заменяется на значение переменной окружения:
|
||||
|
||||
```yaml
|
||||
redmine:
|
||||
api_key: ${REDMINE_API_KEY}
|
||||
|
||||
email:
|
||||
smtp:
|
||||
password: ${SMTP_PASSWORD}
|
||||
```
|
||||
|
||||
Это безопаснее, чем хранить секреты plaintext в YAML. Однако plaintext-секреты
|
||||
**не запрещены** — если вписать `api_key: "abc123"` напрямую, система примет.
|
||||
Права `0600` — основная защита.
|
||||
|
||||
### `report` — настройки содержимого отчёта
|
||||
|
||||
Секция управляет тем, что попадает в сгенерированный отчёт.
|
||||
|
||||
| Поле | Тип | По умолчанию | Описание |
|
||||
|---|---|---|---|
|
||||
| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта |
|
||||
| `status_translation` | map[str,str] | `{}` | Переопределение/дополнение перевода статусов |
|
||||
|
||||
`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`).
|
||||
При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`.
|
||||
CLI-флаг `--no-time` всегда имеет приоритет над YAML.
|
||||
|
||||
**Примеры:**
|
||||
|
||||
```yaml
|
||||
report:
|
||||
no_time: true
|
||||
```
|
||||
|
||||
```bash
|
||||
# Автоматический режим: время не выводится
|
||||
redmine-reporter --commit
|
||||
|
||||
# Ручной режим: report.no_time игнорируется, время выводится
|
||||
redmine-reporter --output report.odt
|
||||
|
||||
# Ручной режим с явным флагом: время не выводится
|
||||
redmine-reporter --output report.odt --no-time
|
||||
```
|
||||
|
||||
### `report.status_translation` — перевод статусов
|
||||
|
||||
По умолчанию статусы Redmine переводятся встроенным словарём
|
||||
(`New` → `В работе`, `Closed` → `Закрыто` и т.д.). Секция
|
||||
`report.status_translation` переопределяет отдельные переводы и/или
|
||||
добавляет новые статусы; не указанные здесь статусы переводятся
|
||||
встроенным словарём, а совсем неизвестные выводятся как есть.
|
||||
|
||||
```yaml
|
||||
report:
|
||||
status_translation:
|
||||
"New": "Новая"
|
||||
"Wait Release": "Ожидает релиза"
|
||||
"Custom Status": "Кастомный статус"
|
||||
```
|
||||
|
||||
## Разрешение выходного пути
|
||||
|
||||
Функция `resolve_output_path()` определяет итоговый путь к файлу:
|
||||
|
||||
1. `--output` не указан → консольный вывод.
|
||||
2. `--output xlsx` (bare format) → путь формируется как `output.dir / output.filename` с подстановкой `{ext}` = bare format и дат из периода.
|
||||
3. `--output /path/report` (без расширения) → дописывается `.output.default_format`.
|
||||
4. `--output /path/report.csv` (с расширением) → используется как есть.
|
||||
5. `--output /path/report.xyz` (неизвестное расширение) → используется как есть, форматтер выбирается по расширению.
|
||||
|
||||
## Миграция с `.env` на YAML
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
```bash
|
||||
# 1. Генерируем YAML из текущего .env
|
||||
redmine-reporter --init-config
|
||||
|
||||
# 2. Проверяем, что создалось
|
||||
cat ~/.config/redmine-reporter/config.yml
|
||||
|
||||
# 3. Редактируем под себя (шаблон имени, период, etc.)
|
||||
vim ~/.config/redmine-reporter/config.yml
|
||||
```
|
||||
|
||||
### Что делает `--init-config`
|
||||
|
||||
- Читает текущие значения из `.env` и переменных окружения.
|
||||
- Формирует YAML со всеми секциями (`redmine`, `period`, `output`, `report`, `email`).
|
||||
- В конец файла дописывает закомментированный пример `report.status_translation`.
|
||||
- Секреты (`REDMINE_API_KEY`, `SMTP_PASSWORD`) записывает как `${VAR}`, если
|
||||
переменная существует, иначе — пустая строка.
|
||||
- Создаёт файл с правами `0600`, директорию — с `0700`.
|
||||
|
||||
### Флаги миграции
|
||||
|
||||
| Флаг | Назначение |
|
||||
|---|---|
|
||||
| `--init-config` | Создать YAML и выйти |
|
||||
| `--init-config --force` | Перезаписать существующий YAML |
|
||||
| `--config-path PATH` | Путь к YAML-конфигу: загрузка при запуске, запись при `--init-config` и `--commit` (по умолчанию `~/.config/redmine-reporter/config.yml`) |
|
||||
|
||||
Если `DEFAULT_TO_DATE` не задана, а `DEFAULT_FROM_DATE` задана, сгенерированный
|
||||
YAML будет содержать пустое `default_to`, и при запуске инструмент использует
|
||||
сегодняшнюю дату.
|
||||
|
||||
### Проверка после миграции
|
||||
|
||||
```bash
|
||||
# Запустить без .env в текущей директории
|
||||
cd /tmp
|
||||
redmine-reporter --date 2026-06-01--2026-06-30
|
||||
```
|
||||
|
||||
Если отработал — YAML-конфиг читается корректно. Если `REDMINE_URL is required` —
|
||||
проверь права:
|
||||
|
||||
```bash
|
||||
ls -la ~/.config/redmine-reporter/
|
||||
# config.yml должно быть -rw------- (600)
|
||||
# директория должна быть drwx------ (700)
|
||||
```
|
||||
|
||||
### Сосуществование `.env` и YAML
|
||||
|
||||
Можно оставить оба источника. `.env` имеет **более высокий приоритет**, чем YAML:
|
||||
|
||||
```
|
||||
.env значения переопределяют YAML, если заданы
|
||||
YAML работает как базовый слой для всего, что не в .env
|
||||
```
|
||||
|
||||
Автозагрузка `.env` (без `--config`) не перебивает реальные переменные
|
||||
окружения (`override=False`). Исключение — `--config FILE`: указанный `.env`
|
||||
загружается с `override=True` и перебивает переменные окружения (но не CLI-флаги).
|
||||
|
||||
Это safe — если с YAML что-то пойдёт не так, просто положи `.env` обратно.
|
||||
|
||||
### Откат
|
||||
|
||||
```bash
|
||||
rm ~/.config/redmine-reporter/config.yml
|
||||
```
|
||||
|
||||
## `.env` (legacy)
|
||||
|
||||
Для обратной совместимости `.env` продолжает работать без изменений.
|
||||
|
||||
```ini
|
||||
REDMINE_URL=https://red.eltex.loc
|
||||
REDMINE_API_KEY=your-api-key
|
||||
REDMINE_AUTHOR=Кокос А.А.
|
||||
DEFAULT_FROM_DATE=2026-06-01
|
||||
DEFAULT_TO_DATE=2026-06-30
|
||||
```
|
||||
|
||||
Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц
|
||||
как период, стандартная проверка TLS, пустой автор).
|
||||
|
||||
## Безопасность
|
||||
|
||||
- YAML-конфиг: права `0600`, директория `0700`.
|
||||
- Права шире `0600` → warning в stderr при каждом запуске.
|
||||
- Секреты рекомендуется хранить через `${VAR}`, а не plaintext.
|
||||
- `.env` **не рекомендуется** для постоянных настроек — оставьте его только
|
||||
для CI/CD или временных переопределений.
|
||||
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,34 +4,39 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "redmine-reporter"
|
||||
version = "0.1.1"
|
||||
version = "1.11.1"
|
||||
description = "Redmine time-entry based issue reporter for internal use"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
||||
license = { text = "Proprietary" }
|
||||
license = { text = "MIT" }
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Environment :: Console",
|
||||
]
|
||||
requires-python = ">=3.8"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"python-redmine>=2.4.0",
|
||||
"tabulate>=0.9.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"odfpy>=1.4.0",
|
||||
"openpyxl>=3.1.0",
|
||||
"pyyaml>=6.0",
|
||||
"requests>=2.31",
|
||||
"truststore>=0.10",
|
||||
"urllib3>=1.26",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"black>=23.0",
|
||||
"isort>=5.12",
|
||||
"mypy>=1.0",
|
||||
"ruff>=0.1.0",
|
||||
"black>=24.0",
|
||||
"isort>=5.0",
|
||||
"setuptools>=61.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -41,10 +46,19 @@ redmine-reporter = "redmine_reporter.cli:main"
|
||||
where = ["."]
|
||||
include = ["redmine_reporter*"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py38']
|
||||
[tool.setuptools.package-data]
|
||||
"redmine_reporter" = ["templates/template.odt"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.mypy]
|
||||
warn_unused_configs = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
multi_line_output = 3
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.1"
|
||||
__version__ = "1.11.1"
|
||||
|
||||
@@ -1,73 +1,596 @@
|
||||
import sys
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from redminelib.resources import Issue
|
||||
|
||||
import yaml
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
|
||||
from . import __version__
|
||||
from .client import RedmineAPIError, fetch_issues_with_spent_time
|
||||
from .config import Config
|
||||
from .client import fetch_issues_with_spent_time
|
||||
from .formatter import format_compact, format_table
|
||||
from .formatters.factory import get_console_formatter, get_formatter_by_extension
|
||||
from .mailer import send_report
|
||||
from .report_builder import build_grouped_report, calculate_summary
|
||||
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]:
|
||||
if "--" not in date_arg:
|
||||
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
||||
parts = date_arg.split("--", 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError("Invalid date range format")
|
||||
return 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}"
|
||||
datetime_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
|
||||
if re.fullmatch(date_pattern, from_date) and re.fullmatch(date_pattern, to_date):
|
||||
fmt = "%Y-%m-%d"
|
||||
elif re.fullmatch(datetime_pattern, from_date) and re.fullmatch(
|
||||
datetime_pattern, to_date
|
||||
):
|
||||
fmt = "%Y-%m-%dT%H:%M:%S"
|
||||
else:
|
||||
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
||||
|
||||
try:
|
||||
start = datetime.strptime(from_date, fmt)
|
||||
end = datetime.strptime(to_date, fmt)
|
||||
except ValueError as e:
|
||||
raise ValueError("Date range contains invalid calendar date") from e
|
||||
|
||||
if start > end:
|
||||
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()
|
||||
|
||||
|
||||
def _run_init_config(config_path: str, force: bool) -> int:
|
||||
"""Создаёт YAML-конфиг из текущих настроек окружения.
|
||||
|
||||
Подгружает .env из текущей директории (override=False: реальные
|
||||
переменные окружения не перебиваются), чтобы --init-config видел
|
||||
значения, заданные только в .env (#64). Поиск идёт от cwd
|
||||
(usecwd=True): find_dotenv() без него стартует от директории
|
||||
cli.py и .env пользователя не находит.
|
||||
"""
|
||||
load_dotenv(find_dotenv(usecwd=True), override=False)
|
||||
|
||||
path = Path(config_path)
|
||||
|
||||
if path.exists() and not force:
|
||||
print(
|
||||
f"⚠️ {path} already exists.\n Use --init-config --force to overwrite.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Собираем значения из окружения
|
||||
data = {
|
||||
"redmine": {
|
||||
"url": os.getenv("REDMINE_URL", "").strip().rstrip("/"),
|
||||
"api_key": ("${REDMINE_API_KEY}" if os.getenv("REDMINE_API_KEY") else ""),
|
||||
"author": os.getenv("REDMINE_AUTHOR", "").strip(),
|
||||
"verify_ssl": True,
|
||||
},
|
||||
"period": {
|
||||
"precision": "date",
|
||||
"default_from": os.getenv("DEFAULT_FROM_DATE", "").strip(),
|
||||
"default_to": os.getenv("DEFAULT_TO_DATE", "").strip(),
|
||||
"dynamic": False,
|
||||
},
|
||||
"output": {
|
||||
"dir": "",
|
||||
"filename": "{author}_{from}_{to}.{ext}",
|
||||
"default_format": "xlsx",
|
||||
},
|
||||
"report": {
|
||||
"no_time": False,
|
||||
},
|
||||
"email": {
|
||||
"html": False,
|
||||
"smtp": {
|
||||
"host": "",
|
||||
"port": 587,
|
||||
"user": "",
|
||||
"password": ("${SMTP_PASSWORD}" if os.getenv("SMTP_PASSWORD") else ""),
|
||||
"tls": True,
|
||||
},
|
||||
"from": "",
|
||||
"to": [],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Отчёт {author} за {period}",
|
||||
"body_text": "Во вложении отчёт.",
|
||||
"attach": True,
|
||||
},
|
||||
}
|
||||
|
||||
ensure_config_dir(path.parent)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
yaml.dump(
|
||||
data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
|
||||
)
|
||||
fh.write(
|
||||
"\n"
|
||||
"# report.status_translation: переопределение/дополнение перевода\n"
|
||||
"# статусов Redmine. Без секции используется встроенный словарь.\n"
|
||||
"# Пример:\n"
|
||||
"# report:\n"
|
||||
"# status_translation:\n"
|
||||
'# "New": "Новая"\n'
|
||||
'# "Wait Release": "Ожидает релиза"\n'
|
||||
)
|
||||
path.chmod(0o600)
|
||||
|
||||
sections_found = [s for s in data if data[s]]
|
||||
print(f"✅ Config written to {path}")
|
||||
print(f" Sections: {', '.join(sections_found)}")
|
||||
print(" Secrets stored as ${VAR} references where detected.")
|
||||
return 0
|
||||
|
||||
|
||||
def _compute_dedup_cutoff() -> Optional[datetime]:
|
||||
"""Compute deduplication cutoff from config.
|
||||
|
||||
If period.precision == 'datetime' and period.last_used.to is set,
|
||||
return the datetime to filter out entries already accounted for.
|
||||
Otherwise return None.
|
||||
"""
|
||||
if Config.get_period_precision() != "datetime":
|
||||
return None
|
||||
|
||||
last_to = Config.get_last_used_to()
|
||||
if not last_to:
|
||||
return None
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_to.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool:
|
||||
"""Возвращает финальное значение no_time.
|
||||
|
||||
Приоритет:
|
||||
1. CLI-флаг --no-time (всегда побеждает).
|
||||
2. YAML report.no_time (только в автоматических режимах --commit/--send).
|
||||
3. Иначе False.
|
||||
"""
|
||||
if cli_flag:
|
||||
return True
|
||||
if is_auto_mode:
|
||||
return Config.get_report_no_time()
|
||||
return False
|
||||
|
||||
|
||||
def _save_and_maybe_send(
|
||||
rows,
|
||||
output_arg: str,
|
||||
author: str,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
no_time: bool,
|
||||
do_send: bool,
|
||||
) -> int:
|
||||
"""Сохраняет отчёт в файл и опционально отправляет по email.
|
||||
|
||||
Returns 0 on success, 1 on error.
|
||||
"""
|
||||
output_ext = os.path.splitext(output_arg)[1].lower()
|
||||
|
||||
if not output_ext:
|
||||
print(
|
||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
formatter = get_formatter_by_extension(
|
||||
output_ext,
|
||||
author=author,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
no_time=no_time,
|
||||
)
|
||||
|
||||
if not formatter:
|
||||
if output_ext == ".odt":
|
||||
print(
|
||||
"❌ odfpy is not installed. Install with: pip install odfpy",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
|
||||
print(
|
||||
f"❌ Неизвестный формат файла: {output_ext!r}. "
|
||||
f"Поддерживаются: {known_exts}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
formatter.save(rows, output_arg)
|
||||
print(f"✅ Report saved to {output_arg}")
|
||||
except Exception as e:
|
||||
fmt = output_ext.lstrip(".").upper()
|
||||
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if do_send:
|
||||
email_config = Config.get_email_config()
|
||||
if email_config is None:
|
||||
print(
|
||||
"❌ Email не настроен. Добавьте секцию 'email' в конфиг "
|
||||
"(~/.config/redmine-reporter/config.yml).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
send_report(
|
||||
email_config,
|
||||
output_arg,
|
||||
author,
|
||||
f"{from_date}--{to_date}",
|
||||
rows=rows,
|
||||
)
|
||||
print(f"📧 Report sent to {', '.join(email_config.to)}")
|
||||
except RedmineAPIError as e:
|
||||
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="redmine-reporter",
|
||||
description="Generate Redmine issue report based on your time entries."
|
||||
description="Generate Redmine issue report based on your time entries.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--date",
|
||||
default=Config.get_default_date_range(),
|
||||
# help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: %(default)s)"
|
||||
help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default from .env or %(default)s)"
|
||||
default=None,
|
||||
help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: current month or from .env)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compact",
|
||||
action="store_true",
|
||||
help="Use compact plain-text output instead of table"
|
||||
help="Use compact plain-text output instead of table",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
help="Path to output file (.odt, .csv, .md, .html, .json, .xlsx). If omitted, prints to stdout.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-time", action="store_true", help="Do not include spent time into table"
|
||||
)
|
||||
parser.add_argument("--url", help="Override Redmine URL from .env (REDMINE_URL)")
|
||||
parser.add_argument(
|
||||
"--api-key", help="Override Redmine API key from .env (REDMINE_API_KEY)"
|
||||
)
|
||||
parser.add_argument("--config", help="Path to .env config file")
|
||||
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug output")
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"%(prog)s {__version__}",
|
||||
help="Show version and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--summary",
|
||||
action="store_true",
|
||||
help="Print summary (total hours by project/version) to stderr",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-id",
|
||||
help="Redmine user ID for the report (default: current user)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-login",
|
||||
help="Redmine user login for the report (alternative to --user-id)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-name",
|
||||
help="Redmine user full name for the report (alternative to --user-id; ambiguous names are rejected)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--by-activity",
|
||||
action="store_true",
|
||||
help="Break down spent time by activity type",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--init-config",
|
||||
action="store_true",
|
||||
help="Generate YAML config from current environment and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Allow overwriting existing config with --init-config",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config-path",
|
||||
default=str(Path.home() / ".config" / "redmine-reporter" / "config.yml"),
|
||||
help="Path for --init-config output (default: ~/.config/redmine-reporter/config.yml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Save used period as last_used in YAML config and auto-commit to file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--send",
|
||||
action="store_true",
|
||||
help="Send generated report via email after saving (requires email section in config)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# --init-config: обработка до всего остального
|
||||
if args.init_config:
|
||||
# Проверка на взаимоисключающие флаги
|
||||
report_flags = [
|
||||
args.date is not None,
|
||||
args.output,
|
||||
args.compact,
|
||||
args.summary,
|
||||
args.user_id,
|
||||
args.user_login,
|
||||
args.user_name,
|
||||
args.no_time,
|
||||
args.by_activity,
|
||||
args.send,
|
||||
]
|
||||
if any(report_flags):
|
||||
print(
|
||||
"❌ --init-config cannot be used with report-generating flags.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return _run_init_config(args.config_path, args.force)
|
||||
|
||||
# Валидация взаимоисключающих флагов пользователя
|
||||
user_args = [args.user_id, args.user_login, args.user_name]
|
||||
if sum(bool(a) for a in user_args) > 1:
|
||||
print(
|
||||
"❌ Specify only one of --user-id, --user-login, or --user-name.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# CLI-переопределения имеют приоритет над .env/env.
|
||||
if args.config:
|
||||
Config.load_config(args.config)
|
||||
Config.set_redmine_url(args.url)
|
||||
Config.set_redmine_api_key(args.api_key)
|
||||
|
||||
# Автозагрузка YAML-конфига
|
||||
yaml_path = args.config_path
|
||||
Config.load_yaml(yaml_path)
|
||||
|
||||
# Настройка уровня логирования
|
||||
if args.debug:
|
||||
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
|
||||
elif args.verbose:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
else:
|
||||
logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
|
||||
|
||||
try:
|
||||
Config.validate()
|
||||
except ValueError as e:
|
||||
print(f"❌ Configuration error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Если --date не указан, используем дефолтный диапазон
|
||||
date_arg = args.date if args.date is not None else Config.get_default_date_range()
|
||||
try:
|
||||
from_date, to_date = parse_date_range(args.date)
|
||||
from_date, to_date = parse_date_range(date_arg)
|
||||
except ValueError as e:
|
||||
print(f"❌ Date error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
issue_hours = fetch_issues_with_spent_time(from_date, to_date)
|
||||
issue_hours = fetch_issues_with_spent_time(
|
||||
from_date,
|
||||
to_date,
|
||||
user_id=args.user_id or args.user_login or args.user_name,
|
||||
by_activity=args.by_activity,
|
||||
dedup_before=_compute_dedup_cutoff(),
|
||||
)
|
||||
except RedmineAPIError as e:
|
||||
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
|
||||
if (args.verbose or args.debug) and e.original is not None:
|
||||
logging.exception("Original Redmine API error")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"❌ Redmine API 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
|
||||
|
||||
if issue_hours is None:
|
||||
if not issue_hours:
|
||||
print("ℹ️ No time entries found in the given period.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]")
|
||||
print(f"✅ Total issues: {len(issue_hours)} [{date_arg}]", file=sys.stderr)
|
||||
|
||||
is_auto_mode = args.commit or args.send
|
||||
no_time = _resolve_no_time(args.no_time, is_auto_mode)
|
||||
|
||||
rows = build_grouped_report(
|
||||
issue_hours,
|
||||
fill_time=not no_time,
|
||||
by_activity=args.by_activity,
|
||||
status_translation=Config.get_status_translation(),
|
||||
)
|
||||
|
||||
if args.summary:
|
||||
summary = calculate_summary(rows, by_activity=args.by_activity)
|
||||
print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr)
|
||||
project_keys = [k for k in sorted(summary) if k.startswith("project:")]
|
||||
activity_keys = [k for k in sorted(summary) if k.startswith("activity:")]
|
||||
for key in project_keys + activity_keys:
|
||||
value = summary[key]
|
||||
if key.startswith("project:"):
|
||||
project = key.split(":", 1)[1]
|
||||
print(f" {project}: {value}h", file=sys.stderr)
|
||||
elif key.startswith("activity:"):
|
||||
activity = key.split(":", 1)[1]
|
||||
print(f" [{activity}]: {value}h", file=sys.stderr)
|
||||
|
||||
if args.output or args.commit:
|
||||
if args.output:
|
||||
output_arg = resolve_output_path(
|
||||
args.output,
|
||||
output_dir=Config.get_output_dir(),
|
||||
filename_template=Config.get_output_filename(),
|
||||
default_format=Config.get_output_default_format(),
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
else:
|
||||
# --commit без --output: используем default_format
|
||||
default_format = Config.get_output_default_format()
|
||||
output_arg = resolve_output_path(
|
||||
default_format,
|
||||
output_dir=Config.get_output_dir(),
|
||||
filename_template=Config.get_output_filename(),
|
||||
default_format=default_format,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
|
||||
if output_arg is None:
|
||||
print(
|
||||
"❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
|
||||
ret = _save_and_maybe_send(
|
||||
rows,
|
||||
output_arg,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
no_time=no_time,
|
||||
do_send=args.send,
|
||||
)
|
||||
if ret != 0:
|
||||
return ret
|
||||
|
||||
elif args.send:
|
||||
# --send без --output и --commit: сохраняем по шаблону и отправляем
|
||||
default_format = Config.get_output_default_format()
|
||||
output_arg = resolve_output_path(
|
||||
default_format,
|
||||
output_dir=Config.get_output_dir(),
|
||||
filename_template=Config.get_output_filename(),
|
||||
default_format=default_format,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
|
||||
if output_arg is None:
|
||||
print(
|
||||
"❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
|
||||
ret = _save_and_maybe_send(
|
||||
rows,
|
||||
output_arg,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
no_time=no_time,
|
||||
do_send=True,
|
||||
)
|
||||
if ret != 0:
|
||||
return ret
|
||||
|
||||
else:
|
||||
if args.compact:
|
||||
formatter = get_console_formatter("compact")
|
||||
else:
|
||||
formatter = get_console_formatter("table")
|
||||
|
||||
if not formatter:
|
||||
print("❌ Неизвестный тип консольного форматтера.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
if args.compact:
|
||||
output = format_compact(issue_hours)
|
||||
else:
|
||||
output = format_table(issue_hours)
|
||||
output = formatter.format(rows)
|
||||
print(output)
|
||||
except Exception as e:
|
||||
print(f"❌ Formatting error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.commit:
|
||||
precision = Config.get_period_precision()
|
||||
dynamic = Config._app.period_dynamic if Config._app else False
|
||||
|
||||
if precision == "datetime":
|
||||
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
|
||||
# метки aware cutoff для дедупликации.
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
from_str = now
|
||||
to_str = now
|
||||
else:
|
||||
from_str = from_date
|
||||
to_str = to_date
|
||||
|
||||
try:
|
||||
save_period_to_config(
|
||||
args.config_path, from_str, to_str, precision, dynamic
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"❌ Не удалось сохранить период в конфиг: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(
|
||||
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,58 +1,399 @@
|
||||
from typing import List, Optional, Dict, Tuple
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
import truststore
|
||||
from redminelib import Redmine
|
||||
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
|
||||
from redminelib.resources import Issue
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from .config import Config
|
||||
from .utils import get_version
|
||||
|
||||
# Таймаут на один HTTP-запрос к Redmine (секунды).
|
||||
REQUEST_TIMEOUT = 30
|
||||
|
||||
# Размер чанка для запроса задач по issue_id, чтобы не превышать лимит длины URL (#21).
|
||||
ISSUE_ID_CHUNK_SIZE = 100
|
||||
|
||||
|
||||
def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[Tuple[Issue, float]]]:
|
||||
class RedmineAPIError(Exception):
|
||||
"""Пользовательское исключение с понятным сообщением об ошибке Redmine API."""
|
||||
|
||||
def __init__(self, message: str, original: Optional[Exception] = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.original = original
|
||||
|
||||
|
||||
def _get_redmine_auth_kwargs() -> Dict[str, Any]:
|
||||
"""Return Redmine auth kwargs. API key has priority over legacy password auth."""
|
||||
api_key = Config.get_redmine_api_key()
|
||||
if api_key:
|
||||
return {"key": api_key}
|
||||
return {
|
||||
"username": Config.get_redmine_user(),
|
||||
"password": Config.get_redmine_password(),
|
||||
}
|
||||
|
||||
|
||||
def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
|
||||
"""Создаёт HTTPAdapter с retry для временных ошибок (#24)."""
|
||||
retry = Retry(
|
||||
total=3,
|
||||
backoff_factor=0.5,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["GET", "HEAD", "OPTIONS"],
|
||||
)
|
||||
return requests.adapters.HTTPAdapter(max_retries=retry)
|
||||
|
||||
|
||||
def _create_redmine() -> Redmine:
|
||||
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24).
|
||||
|
||||
При verify=True подключает системное хранилище CA ОС через
|
||||
truststore.inject_into_ssl() (#62).
|
||||
"""
|
||||
Fetch unique issues linked to time entries of the current user in given date range,
|
||||
along with total spent hours per issue.
|
||||
Returns list of (issue, total_hours) tuples.
|
||||
"""
|
||||
|
||||
verify = Config.get_redmine_verify()
|
||||
if verify is True:
|
||||
# verify_ssl: true — проверка по системному хранилищу CA ОС (truststore),
|
||||
# а не по certifi: корпоративные CA из ОС продолжают работать (#62).
|
||||
truststore.inject_into_ssl()
|
||||
redmine = Redmine(
|
||||
Config.REDMINE_URL,
|
||||
username=Config.REDMINE_USER,
|
||||
password=Config.REDMINE_PASSWORD,
|
||||
requests={'verify': '/etc/ssl/certs/ca-certificates.crt'}
|
||||
Config.get_redmine_url(),
|
||||
**_get_redmine_auth_kwargs(),
|
||||
requests={
|
||||
"verify": verify,
|
||||
"timeout": REQUEST_TIMEOUT,
|
||||
},
|
||||
)
|
||||
|
||||
current_user = redmine.user.get('current')
|
||||
time_entries = redmine.time_entry.filter(
|
||||
user_id=current_user.id,
|
||||
from_date=from_date,
|
||||
to_date=to_date
|
||||
# Монтируем retry-адаптер на сессию для автоматических повторов.
|
||||
# В python-redmine сессия живёт в engine, а redmine.session — контекстный менеджер.
|
||||
retry_adapter = _make_retry_adapter()
|
||||
redmine.engine.session.mount("https://", retry_adapter)
|
||||
redmine.engine.session.mount("http://", retry_adapter)
|
||||
|
||||
return redmine
|
||||
|
||||
|
||||
def _format_redmine_error(exc: Exception) -> str:
|
||||
"""Преобразует исключение Redmine/requests в понятное сообщение."""
|
||||
if isinstance(exc, AuthError):
|
||||
return (
|
||||
"Authentication failed: invalid API key, login or password. "
|
||||
"Check REDMINE_API_KEY / REDMINE_USER / REDMINE_PASSWORD."
|
||||
)
|
||||
if isinstance(exc, ForbiddenError):
|
||||
return (
|
||||
"Access denied: your Redmine account does not have permission "
|
||||
"to read time entries or issues."
|
||||
)
|
||||
if isinstance(exc, ResourceNotFoundError):
|
||||
return "Requested Redmine resource not found: check user/project identifiers."
|
||||
|
||||
# requests HTTPError может быть обёрнуто в python-redmine
|
||||
original = getattr(exc, "response", None)
|
||||
if original is None:
|
||||
original = exc
|
||||
|
||||
response = getattr(original, "response", None)
|
||||
if response is not None and hasattr(response, "status_code"):
|
||||
status = response.status_code
|
||||
if status == 401:
|
||||
return "Authentication failed (HTTP 401): check your API key or login/password."
|
||||
if status == 403:
|
||||
return "Access denied (HTTP 403): insufficient Redmine permissions."
|
||||
if status == 404:
|
||||
return "Redmine endpoint not found (HTTP 404): check REDMINE_URL."
|
||||
if status == 429:
|
||||
return "Too many requests (HTTP 429): Redmine rate limit exceeded."
|
||||
if 500 <= status < 600:
|
||||
return f"Redmine server error (HTTP {status}): try again later."
|
||||
return f"Redmine API returned HTTP {status}."
|
||||
|
||||
if isinstance(exc, requests.exceptions.Timeout):
|
||||
return f"Redmine request timed out after {REQUEST_TIMEOUT} seconds."
|
||||
if isinstance(exc, requests.exceptions.ConnectionError):
|
||||
return "Cannot connect to Redmine: check the URL and network."
|
||||
if isinstance(exc, requests.exceptions.RequestException):
|
||||
return f"Network error while calling Redmine: {exc}"
|
||||
|
||||
return str(exc)
|
||||
|
||||
|
||||
def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
|
||||
"""Загружает справочник типов активности time entries.
|
||||
|
||||
Возвращает словарь id -> name. Если справочник недоступен,
|
||||
возвращает пустой словарь — тогда будем использовать данные из самих entries.
|
||||
"""
|
||||
try:
|
||||
activities = redmine.enumeration.filter(resource="time_entry_activities")
|
||||
return {int(a.id): str(a.name) for a in activities}
|
||||
except Exception 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 {}
|
||||
|
||||
|
||||
def _get_activity_name(entry, activities: Dict[int, str]) -> str:
|
||||
"""Определяет название активности для time entry."""
|
||||
activity = getattr(entry, "activity", None)
|
||||
if activity is None:
|
||||
return "<N/A>"
|
||||
|
||||
# activity может быть объектом с id/name или просто значением
|
||||
activity_id = getattr(activity, "id", None)
|
||||
if activity_id is not None:
|
||||
name = activities.get(int(activity_id))
|
||||
if name:
|
||||
return name
|
||||
activity_name = getattr(activity, "name", None)
|
||||
if activity_name:
|
||||
return str(activity_name)
|
||||
return str(activity_id)
|
||||
|
||||
return str(activity)
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> Optional[datetime]:
|
||||
"""Parse a datetime from Redmine API response.
|
||||
|
||||
Accepts datetime objects, ISO strings (with or without timezone),
|
||||
or None. Returns a timezone-aware datetime or None.
|
||||
Naive datetimes are treated as UTC (#58).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return _ensure_aware_utc(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return _ensure_aware_utc(dt)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_aware_utc(dt: datetime) -> datetime:
|
||||
"""Возвращает aware datetime; naive трактуется как UTC (#58)."""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
|
||||
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
|
||||
all_issues: List[Issue] = []
|
||||
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
|
||||
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
|
||||
issue_list_str = ",".join(str(x) for x in chunk)
|
||||
issues = redmine.issue.filter(
|
||||
issue_id=issue_list_str, status_id="*", sort="project:asc"
|
||||
)
|
||||
all_issues.extend(issues)
|
||||
return all_issues
|
||||
|
||||
|
||||
def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int:
|
||||
"""Преобразует строковый идентификатор пользователя в числовой ID.
|
||||
|
||||
Если аргумент — число, возвращает его как есть.
|
||||
Если строка, пытается найти пользователя по логину или имени.
|
||||
"""
|
||||
if isinstance(user_arg, int):
|
||||
return user_arg
|
||||
|
||||
text = str(user_arg).strip()
|
||||
if not text:
|
||||
raise RedmineAPIError("User identifier cannot be empty.")
|
||||
|
||||
# Сначала пробуем интерпретировать как числовой ID
|
||||
if text.isdigit():
|
||||
return int(text)
|
||||
|
||||
# Затем ищем по логину
|
||||
try:
|
||||
users = redmine.user.filter(login=text)
|
||||
# Фильтр Redmine по логину неточный (substring-поиск), поэтому
|
||||
# выбираем только точные регистрозависимые совпадения логина (#60).
|
||||
exact_matches = [u for u in users if getattr(u, "login", None) == text]
|
||||
if len(exact_matches) == 1:
|
||||
return int(exact_matches[0].id)
|
||||
if len(exact_matches) > 1:
|
||||
matches = ", ".join(str(u.id) for u in exact_matches[:5])
|
||||
raise RedmineAPIError(
|
||||
f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID."
|
||||
)
|
||||
except RedmineAPIError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RedmineAPIError(
|
||||
f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}",
|
||||
original=exc,
|
||||
) from exc
|
||||
|
||||
# Потом по имени
|
||||
try:
|
||||
users = redmine.user.filter(name=text)
|
||||
if len(users) == 1:
|
||||
return int(users[0].id)
|
||||
if len(users) > 1:
|
||||
matches = ", ".join(str(getattr(u, "login", u.id)) for u in users[:5])
|
||||
raise RedmineAPIError(
|
||||
f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID."
|
||||
)
|
||||
except RedmineAPIError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RedmineAPIError(
|
||||
f"Cannot resolve user name '{text}': {_format_redmine_error(exc)}",
|
||||
original=exc,
|
||||
) from exc
|
||||
|
||||
raise RedmineAPIError(
|
||||
f"User '{text}' not found. Check the login/name or use --user-id with numeric Redmine ID."
|
||||
)
|
||||
|
||||
# Агрегируем часы по issue.id
|
||||
|
||||
def _get_current_user_id(redmine: Redmine) -> int:
|
||||
"""Возвращает ID текущего пользователя."""
|
||||
try:
|
||||
current_user = redmine.user.get("current")
|
||||
return int(current_user.id)
|
||||
except Exception as exc:
|
||||
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
||||
|
||||
|
||||
def fetch_issues_with_spent_time(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
user_id: Optional[Union[int, str]] = None,
|
||||
by_activity: bool = False,
|
||||
dedup_before: Optional[datetime] = None,
|
||||
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]:
|
||||
"""
|
||||
Fetch unique issues linked to time entries of the given user in date range,
|
||||
along with total spent hours per issue.
|
||||
If user_id is None, uses current user.
|
||||
If by_activity is True, returns per-activity breakdown as third tuple element.
|
||||
If dedup_before is set, entries with both fields set are kept only when
|
||||
created_on AND updated_on are both >= dedup_before; an entry is excluded
|
||||
if at least one of the fields is before the cutoff (dedup_before).
|
||||
Entries with both fields missing (None) are kept;
|
||||
if only one field is set, that field alone decides (>= cutoff keeps the entry).
|
||||
Returns list of (issue, total_hours, activities) tuples.
|
||||
Raises RedmineAPIError on API/auth/network failures.
|
||||
"""
|
||||
|
||||
try:
|
||||
redmine = _create_redmine()
|
||||
target_user_id = (
|
||||
_resolve_user_id(redmine, user_id)
|
||||
if user_id is not None
|
||||
else _get_current_user_id(redmine)
|
||||
)
|
||||
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
|
||||
time_entries = list(
|
||||
redmine.time_entry.filter(
|
||||
user_id=target_user_id, from_date=from_date, to_date=to_date
|
||||
)
|
||||
)
|
||||
except RedmineAPIError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
||||
|
||||
# Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте.
|
||||
# Если оба поля заданы, запись сохраняется, только если created_on И updated_on
|
||||
# оба >= dedup_before; если хотя бы одно из полей < dedup_before,
|
||||
# запись исключается.
|
||||
# Если оба поля None — запись сохраняется; если задано только одно поле,
|
||||
# решает оно (>= dedup_before → запись сохраняется).
|
||||
if dedup_before is not None:
|
||||
# Нормализуем cutoff к aware UTC (#58): naive cutoff трактуем как UTC,
|
||||
# чтобы сравнение с нормализованными created_on/updated_on было корректным.
|
||||
dedup_before = _ensure_aware_utc(dedup_before)
|
||||
filtered: list = []
|
||||
try:
|
||||
for entry in time_entries:
|
||||
created = _parse_datetime(getattr(entry, "created_on", None))
|
||||
updated = _parse_datetime(getattr(entry, "updated_on", None))
|
||||
|
||||
if created is None and updated is None:
|
||||
filtered.append(entry)
|
||||
elif created is not None and updated is not None:
|
||||
if created >= dedup_before and updated >= dedup_before:
|
||||
filtered.append(entry)
|
||||
elif created is not None and created >= dedup_before:
|
||||
filtered.append(entry)
|
||||
elif updated is not None and updated >= dedup_before:
|
||||
filtered.append(entry)
|
||||
except TypeError as exc:
|
||||
raise RedmineAPIError(
|
||||
f"Failed to compare time entry dates with deduplication cutoff: {exc}",
|
||||
original=exc,
|
||||
) from exc
|
||||
time_entries = filtered
|
||||
|
||||
# Агрегируем часы по issue.id (и активности, если требуется)
|
||||
spent_time: Dict[int, float] = {}
|
||||
spent_by_activity: Dict[int, Dict[str, float]] = {}
|
||||
issue_ids = set()
|
||||
for entry in time_entries:
|
||||
if hasattr(entry, 'issue') and entry.issue and hasattr(entry, 'hours'):
|
||||
if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
|
||||
iid = entry.issue.id
|
||||
hours = float(entry.hours)
|
||||
issue_ids.add(iid)
|
||||
spent_time[iid] = spent_time.get(iid, 0.0) + float(entry.hours)
|
||||
spent_time[iid] = spent_time.get(iid, 0.0) + hours
|
||||
|
||||
if by_activity:
|
||||
activity_name = _get_activity_name(entry, activities_lookup)
|
||||
by_act = spent_by_activity.setdefault(iid, {})
|
||||
by_act[activity_name] = by_act.get(activity_name, 0.0) + hours
|
||||
|
||||
if not issue_ids:
|
||||
return None
|
||||
|
||||
# Загружаем полные объекты задач
|
||||
issue_list_str = ','.join(str(i) for i in issue_ids)
|
||||
issues = redmine.issue.filter(
|
||||
issue_id=issue_list_str,
|
||||
status_id='*',
|
||||
sort='project:asc'
|
||||
# Загружаем полные объекты задач чанками (#21)
|
||||
try:
|
||||
sorted_ids = sorted(issue_ids)
|
||||
issues = _fetch_issues_chunked(redmine, sorted_ids)
|
||||
except Exception as exc:
|
||||
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
||||
|
||||
# #61: задачи могли не вернуться из issue.filter (нет прав / удалены) —
|
||||
# предупреждаем о выпавших задачах и потерянных часах, но строим отчёт
|
||||
# по доступным данным.
|
||||
returned_ids = {issue.id for issue in issues}
|
||||
missing_ids = sorted(issue_ids - returned_ids)
|
||||
if missing_ids:
|
||||
lost_hours = sum(spent_time[iid] for iid in missing_ids)
|
||||
print(
|
||||
f"⚠️ {len(missing_ids)} issue(s) unavailable (no access or deleted; "
|
||||
f"IDs: {', '.join(str(i) for i in missing_ids)}): "
|
||||
f"{lost_hours:g}h excluded from the report.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Сопоставляем задачи с суммарным временем
|
||||
# Сопоставляем задачи с суммарным временем.
|
||||
# Сортировка выполняется в report_builder.build_grouped_report,
|
||||
# здесь оставляем порядок API как есть.
|
||||
result = []
|
||||
for issue in issues:
|
||||
total_hours = spent_time.get(issue.id, 0.0)
|
||||
result.append((issue, total_hours))
|
||||
|
||||
# Сортируем по (проект, версия)
|
||||
result.sort(key=lambda x: (str(x[0].project), get_version(x[0])))
|
||||
iid = issue.id
|
||||
if iid not in spent_time:
|
||||
continue
|
||||
total_hours = spent_time[iid]
|
||||
activity_breakdown = spent_by_activity.get(iid) if by_activity else None
|
||||
result.append((issue, total_hours, activity_breakdown))
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,29 +1,534 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, Union
|
||||
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .yaml_config import check_file_permissions, resolve_env_vars
|
||||
|
||||
load_dotenv()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_REDMINE_VERIFY: Union[bool, str] = True
|
||||
FALSE_VALUES = {"0", "false", "no", "off"}
|
||||
TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmtpConfig:
|
||||
host: str = ""
|
||||
port: int = 587
|
||||
user: str = ""
|
||||
password: str = ""
|
||||
tls: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailConfig:
|
||||
smtp: SmtpConfig = field(default_factory=SmtpConfig)
|
||||
from_: str = ""
|
||||
to: list = field(default_factory=list)
|
||||
cc: list = field(default_factory=list)
|
||||
bcc: list = field(default_factory=list)
|
||||
subject: str = "Отчёт {author} за {period}"
|
||||
body_text: str = "Во вложении отчёт."
|
||||
attach: bool = True
|
||||
html: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class PeriodLastUsed:
|
||||
from_: str = ""
|
||||
to: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
redmine_url: str = ""
|
||||
redmine_api_key: str = ""
|
||||
redmine_author: str = ""
|
||||
redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY
|
||||
period_precision: str = "date"
|
||||
period_default_from: str = ""
|
||||
period_default_to: str = ""
|
||||
period_dynamic: bool = False
|
||||
period_last_used_from: str = ""
|
||||
period_last_used_to: str = ""
|
||||
output_dir: str = ""
|
||||
output_filename: str = "{author}_{from}_{to}.{ext}"
|
||||
output_default_format: str = "xlsx"
|
||||
report_no_time: bool = False
|
||||
report_status_translation: Dict[str, str] = field(default_factory=dict)
|
||||
email: EmailConfig = field(default_factory=EmailConfig)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Union[str, Path]) -> "AppConfig":
|
||||
"""Загружает настройки из YAML-файла.
|
||||
|
||||
Если файл не существует — возвращает конфиг со значениями по умолчанию.
|
||||
Неизвестные ключи верхнего уровня логируются с warning.
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
return cls()
|
||||
|
||||
# Проверяем права файла
|
||||
for warning in check_file_permissions(path):
|
||||
logger.warning(warning)
|
||||
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
raw = yaml.safe_load(fh) or {}
|
||||
|
||||
# Предупреждаем о неизвестных ключах верхнего уровня
|
||||
known_keys = {
|
||||
"redmine",
|
||||
"period",
|
||||
"output",
|
||||
"report",
|
||||
"email",
|
||||
}
|
||||
for key in raw:
|
||||
if key not in known_keys:
|
||||
logger.warning("Unknown top-level key in config: %s", key)
|
||||
|
||||
return cls(
|
||||
redmine_url=cls._resolve_str(raw, "redmine", "url"),
|
||||
redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"),
|
||||
redmine_author=cls._resolve_str(raw, "redmine", "author"),
|
||||
redmine_verify=cls._resolve_verify(raw),
|
||||
period_precision=cls._resolve_str(raw, "period", "precision") or "date",
|
||||
period_default_from=cls._resolve_str(raw, "period", "default_from"),
|
||||
period_default_to=cls._resolve_str(raw, "period", "default_to"),
|
||||
period_dynamic=cls._resolve_bool(raw, "period", "dynamic"),
|
||||
period_last_used_from=cls._resolve_str(raw, "period", "last_used", "from"),
|
||||
period_last_used_to=cls._resolve_str(raw, "period", "last_used", "to"),
|
||||
output_dir=cls._resolve_str(raw, "output", "dir"),
|
||||
output_filename=cls._resolve_str(raw, "output", "filename")
|
||||
or "{author}_{from}_{to}.{ext}",
|
||||
output_default_format=cls._resolve_str(raw, "output", "default_format")
|
||||
or "xlsx",
|
||||
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),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "AppConfig":
|
||||
"""Загружает настройки из переменных окружения."""
|
||||
return cls(
|
||||
redmine_url=os.getenv("REDMINE_URL", "").strip().rstrip("/"),
|
||||
redmine_api_key=os.getenv("REDMINE_API_KEY", "").strip(),
|
||||
redmine_author=os.getenv("REDMINE_AUTHOR", "").strip(),
|
||||
period_default_from=os.getenv("DEFAULT_FROM_DATE", "").strip(),
|
||||
period_default_to=os.getenv("DEFAULT_TO_DATE", "").strip(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_str(raw: dict, section: str, *keys: str) -> str:
|
||||
value = raw.get(section)
|
||||
for key in keys:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(key)
|
||||
else:
|
||||
value = None
|
||||
break
|
||||
if isinstance(value, str):
|
||||
return resolve_env_vars(value)
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_str_dict(raw: dict, section: str, key: str) -> Dict[str, str]:
|
||||
"""Читает вложенный mapping строк из YAML-секции.
|
||||
|
||||
Не-mapping значение логируется с warning и игнорируется.
|
||||
Ключи и значения приводятся к str, ${VAR} в значениях резолвится.
|
||||
Записи со значением null или не-scalar (dict/list) пропускаются
|
||||
с warning — иначе null стал бы строкой "None", а dict — repr.
|
||||
"""
|
||||
section_value = raw.get(section)
|
||||
if not isinstance(section_value, dict):
|
||||
return {}
|
||||
value = section_value.get(key)
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
logger.warning(
|
||||
"Config %s.%s must be a mapping, got %s — ignoring",
|
||||
section,
|
||||
key,
|
||||
type(value).__name__,
|
||||
)
|
||||
return {}
|
||||
result: Dict[str, str] = {}
|
||||
for k, v in value.items():
|
||||
if v is None or isinstance(v, (dict, list)):
|
||||
logger.warning(
|
||||
"Config %s.%s entry %r must be a scalar, got %s — skipping",
|
||||
section,
|
||||
key,
|
||||
k,
|
||||
type(v).__name__,
|
||||
)
|
||||
continue
|
||||
result[str(k)] = resolve_env_vars(str(v))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
|
||||
value = raw.get(section, {}).get(key)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.lower()
|
||||
if normalized in TRUE_VALUES:
|
||||
return True
|
||||
if normalized in FALSE_VALUES:
|
||||
return False
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _resolve_verify(cls, raw: dict) -> 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")
|
||||
if value is None:
|
||||
return DEFAULT_REDMINE_VERIFY
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.lower()
|
||||
if normalized in FALSE_VALUES:
|
||||
return False
|
||||
if normalized in TRUE_VALUES:
|
||||
return True
|
||||
return resolve_env_vars(value)
|
||||
return DEFAULT_REDMINE_VERIFY
|
||||
|
||||
@classmethod
|
||||
def _resolve_email(cls, raw: dict) -> EmailConfig:
|
||||
email_raw = raw.get("email", {}) or {}
|
||||
smtp_raw = email_raw.get("smtp", {}) or {}
|
||||
|
||||
return EmailConfig(
|
||||
smtp=SmtpConfig(
|
||||
host=cls._safe_str(smtp_raw.get("host")),
|
||||
port=cls._safe_int(smtp_raw.get("port"), 587),
|
||||
user=cls._safe_str(smtp_raw.get("user")),
|
||||
password=resolve_env_vars(cls._safe_str(smtp_raw.get("password"))),
|
||||
tls=cls._safe_bool(smtp_raw.get("tls"), True),
|
||||
),
|
||||
from_=cls._safe_str(email_raw.get("from")),
|
||||
to=cls._safe_list(email_raw.get("to")),
|
||||
cc=cls._safe_list(email_raw.get("cc")),
|
||||
bcc=cls._safe_list(email_raw.get("bcc")),
|
||||
subject=resolve_env_vars(
|
||||
cls._safe_str(email_raw.get("subject")) or "Отчёт {author} за {period}"
|
||||
),
|
||||
body_text=resolve_env_vars(
|
||||
cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт."
|
||||
),
|
||||
attach=cls._safe_bool(email_raw.get("attach"), True),
|
||||
html=cls._safe_bool(email_raw.get("html"), False),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_str(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value, default: int = 0) -> int:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _safe_bool(value, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.lower()
|
||||
if normalized in TRUE_VALUES:
|
||||
return True
|
||||
if normalized in FALSE_VALUES:
|
||||
return False
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _safe_list(value) -> list:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(v) for v in value]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
class Config:
|
||||
REDMINE_URL = os.getenv("REDMINE_URL", "").rstrip("/")
|
||||
REDMINE_USER = os.getenv("REDMINE_USER")
|
||||
REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD")
|
||||
DEFAULT_FROM_DATE = os.getenv("DEFAULT_FROM_DATE")
|
||||
DEFAULT_TO_DATE = os.getenv("DEFAULT_TO_DATE")
|
||||
_cli_url: str | None = None
|
||||
_cli_api_key: str | None = None
|
||||
_app: AppConfig | None = None
|
||||
|
||||
@classmethod
|
||||
def load_yaml(cls, path: str) -> None:
|
||||
"""Загружает конфигурацию приложения.
|
||||
|
||||
Сначала подгружает .env из текущей директории (override=False:
|
||||
переменные окружения не перебиваются), затем читает YAML-конфиг.
|
||||
Не бросает исключений при отсутствии файла.
|
||||
"""
|
||||
load_dotenv(override=False)
|
||||
cls._app = AppConfig.from_yaml(path)
|
||||
|
||||
@classmethod
|
||||
def load_config(cls, path: str) -> None:
|
||||
"""Загружает переменные из указанного .env-файла с override."""
|
||||
load_dotenv(path, override=True)
|
||||
|
||||
@classmethod
|
||||
def set_redmine_url(cls, url: str) -> None:
|
||||
cls._cli_url = url.strip().rstrip("/") if url else None
|
||||
|
||||
@classmethod
|
||||
def set_redmine_api_key(cls, key: str) -> None:
|
||||
cls._cli_api_key = key.strip() if key else None
|
||||
|
||||
@classmethod
|
||||
def get_redmine_url(cls) -> str:
|
||||
if cls._cli_url is not None:
|
||||
return cls._cli_url
|
||||
env = os.getenv("REDMINE_URL", "").strip().rstrip("/")
|
||||
if env:
|
||||
return env
|
||||
if cls._app:
|
||||
return cls._app.redmine_url.strip().rstrip("/")
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def get_redmine_api_key(cls) -> str:
|
||||
if cls._cli_api_key is not None:
|
||||
return cls._cli_api_key
|
||||
env = os.getenv("REDMINE_API_KEY", "").strip()
|
||||
if env:
|
||||
return env
|
||||
if cls._app:
|
||||
return cls._app.redmine_api_key
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def get_redmine_user(cls) -> str:
|
||||
return os.getenv("REDMINE_USER", "").strip()
|
||||
|
||||
@classmethod
|
||||
def get_redmine_password(cls) -> str:
|
||||
return os.getenv("REDMINE_PASSWORD", "").strip()
|
||||
|
||||
@classmethod
|
||||
def get_redmine_verify(cls) -> Union[bool, str]:
|
||||
value = os.getenv("REDMINE_VERIFY", "").strip()
|
||||
if value:
|
||||
normalized = value.lower()
|
||||
if normalized in FALSE_VALUES:
|
||||
return False
|
||||
if normalized in TRUE_VALUES:
|
||||
return True
|
||||
return value
|
||||
if cls._app:
|
||||
return cls._app.redmine_verify
|
||||
return DEFAULT_REDMINE_VERIFY
|
||||
|
||||
@classmethod
|
||||
def get_author(cls, cli_author: str = "") -> str:
|
||||
if cli_author:
|
||||
return cli_author
|
||||
env = os.getenv("REDMINE_AUTHOR", "").strip()
|
||||
if env:
|
||||
return env
|
||||
if cls._app:
|
||||
return cls._app.redmine_author
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def get_period_precision(cls) -> str:
|
||||
if cls._app:
|
||||
return cls._app.period_precision or "date"
|
||||
return "date"
|
||||
|
||||
@classmethod
|
||||
def get_last_used_from(cls) -> str:
|
||||
if cls._app:
|
||||
return cls._app.period_last_used_from
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def get_last_used_to(cls) -> str:
|
||||
if cls._app:
|
||||
return cls._app.period_last_used_to
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def get_output_dir(cls) -> str:
|
||||
if cls._app:
|
||||
return cls._app.output_dir
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def get_output_filename(cls) -> str:
|
||||
if cls._app:
|
||||
return cls._app.output_filename or "{author}_{from}_{to}.{ext}"
|
||||
return "{author}_{from}_{to}.{ext}"
|
||||
|
||||
@classmethod
|
||||
def get_output_default_format(cls) -> str:
|
||||
if cls._app:
|
||||
return cls._app.output_default_format or "xlsx"
|
||||
return "xlsx"
|
||||
|
||||
@classmethod
|
||||
def get_report_no_time(cls) -> bool:
|
||||
"""Возвращает report.no_time из YAML-конфига (по умолчанию False)."""
|
||||
if cls._app:
|
||||
return cls._app.report_no_time
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_status_translation(cls) -> Dict[str, str]:
|
||||
"""Полный словарь перевода статусов: встроенный + переопределения из YAML.
|
||||
|
||||
Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется.
|
||||
Импорт ленивый: config.py не должен тянуть redminelib при импорте.
|
||||
"""
|
||||
from .report_builder import STATUS_TRANSLATION
|
||||
|
||||
overrides = cls._app.report_status_translation if cls._app else {}
|
||||
return {**STATUS_TRANSLATION, **overrides}
|
||||
|
||||
@classmethod
|
||||
def get_email_config(cls) -> "EmailConfig | None":
|
||||
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""
|
||||
if cls._app is None:
|
||||
return None
|
||||
email = cls._app.email
|
||||
if not email.smtp.host:
|
||||
return None
|
||||
return email
|
||||
|
||||
@classmethod
|
||||
def get_default_date_range(cls) -> str:
|
||||
if cls.DEFAULT_FROM_DATE and cls.DEFAULT_TO_DATE:
|
||||
return f"{cls.DEFAULT_FROM_DATE}--{cls.DEFAULT_TO_DATE}"
|
||||
# fallback hardcoded
|
||||
return "2025-12-19--2026-01-31"
|
||||
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
|
||||
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
|
||||
today = date.today()
|
||||
today_str = today.isoformat()
|
||||
|
||||
if from_env:
|
||||
return f"{from_env}--{to_env or today_str}"
|
||||
|
||||
if (
|
||||
cls._app
|
||||
and cls._app.period_dynamic
|
||||
and cls._app.period_last_used_from
|
||||
and cls._app.period_last_used_to
|
||||
):
|
||||
nf, nt = compute_next_period(
|
||||
cls._app.period_last_used_from,
|
||||
cls._app.period_last_used_to,
|
||||
cls._app.period_precision or "date",
|
||||
)
|
||||
return f"{nf}--{nt}"
|
||||
|
||||
if cls._app and cls._app.period_default_from:
|
||||
default_to = cls._app.period_default_to or today_str
|
||||
return f"{cls._app.period_default_from}--{default_to}"
|
||||
|
||||
start = today.replace(day=1)
|
||||
return f"{start.isoformat()}--{today_str}"
|
||||
|
||||
@classmethod
|
||||
def validate(cls) -> None:
|
||||
if not cls.REDMINE_URL:
|
||||
url = cls.get_redmine_url()
|
||||
if not url:
|
||||
raise ValueError("REDMINE_URL is required (set via env or .env)")
|
||||
if not cls.REDMINE_USER:
|
||||
raise ValueError("REDMINE_USER is required")
|
||||
if not cls.REDMINE_PASSWORD:
|
||||
raise ValueError("REDMINE_PASSWORD is required")
|
||||
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():
|
||||
return
|
||||
if not (cls.get_redmine_user() and cls.get_redmine_password()):
|
||||
raise ValueError(
|
||||
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
|
||||
)
|
||||
|
||||
|
||||
def compute_next_period(
|
||||
last_from: str, last_to: str, precision: str
|
||||
) -> tuple[str, str]:
|
||||
"""Compute the next report period based on the last committed period.
|
||||
|
||||
- For a full calendar month → next full calendar month.
|
||||
- For an arbitrary range → same duration, starting the day after last_to.
|
||||
- For datetime precision → same duration, starting 1 second after last_to.
|
||||
"""
|
||||
from datetime import datetime as dt_mod
|
||||
|
||||
if precision == "datetime":
|
||||
fmt = "%Y-%m-%dT%H:%M:%S"
|
||||
from_dt = dt_mod.fromisoformat(last_from.replace("Z", "+00:00"))
|
||||
to_dt = dt_mod.fromisoformat(last_to.replace("Z", "+00:00"))
|
||||
duration = to_dt - from_dt
|
||||
next_from_dt = to_dt + timedelta(seconds=1)
|
||||
next_to_dt = next_from_dt + duration
|
||||
return next_from_dt.strftime(fmt), next_to_dt.strftime(fmt)
|
||||
|
||||
from_parts = last_from.split("-")
|
||||
to_parts = last_to.split("-")
|
||||
from_d = date(int(from_parts[0]), int(from_parts[1]), int(from_parts[2]))
|
||||
to_d = date(int(to_parts[0]), int(to_parts[1]), int(to_parts[2]))
|
||||
|
||||
first_of_month = from_d.replace(day=1)
|
||||
if from_d == first_of_month:
|
||||
if to_d.month == 12:
|
||||
last_of_month = date(to_d.year, 12, 31)
|
||||
else:
|
||||
next_first = date(to_d.year, to_d.month + 1, 1)
|
||||
last_of_month = next_first - timedelta(days=1)
|
||||
if to_d == last_of_month:
|
||||
if to_d.month == 12:
|
||||
nstart = date(to_d.year + 1, 1, 1)
|
||||
else:
|
||||
nstart = date(to_d.year, to_d.month + 1, 1)
|
||||
if nstart.month == 12:
|
||||
nend = date(nstart.year, 12, 31)
|
||||
else:
|
||||
nend = date(nstart.year, nstart.month + 1, 1) - timedelta(days=1)
|
||||
return nstart.isoformat(), nend.isoformat()
|
||||
|
||||
duration = to_d - from_d
|
||||
next_from = to_d + timedelta(days=1)
|
||||
next_to = next_from + duration
|
||||
return next_from.isoformat(), next_to.isoformat()
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
from typing import List, Tuple
|
||||
from redminelib.resources import Issue
|
||||
from .utils import get_version
|
||||
|
||||
|
||||
STATUS_TRANSLATION = {
|
||||
'Closed': 'Закрыто',
|
||||
'Re-opened': 'В работе',
|
||||
'New': 'В работе',
|
||||
'Resolved': 'Решена',
|
||||
'Pending': 'Ожидание',
|
||||
'Feedback': 'В работе',
|
||||
'In Progress': 'В работе',
|
||||
'Rejected': 'Закрыто',
|
||||
'Confirming': 'Ожидание',
|
||||
}
|
||||
|
||||
|
||||
def hours_to_human(hours: float) -> str:
|
||||
if hours <= 0:
|
||||
return "0ч"
|
||||
|
||||
total_minutes = round(hours * 60)
|
||||
h = total_minutes // 60
|
||||
m = total_minutes % 60
|
||||
parts = []
|
||||
|
||||
if h:
|
||||
parts.append(f"{h}ч")
|
||||
if m:
|
||||
parts.append(f"{m}м")
|
||||
|
||||
return " ".join(parts) if parts else "0ч"
|
||||
|
||||
|
||||
def format_compact(issue_hours: List[Tuple[Issue, float]]) -> str:
|
||||
lines = []
|
||||
prev_project = None
|
||||
prev_version = None
|
||||
|
||||
for issue, hours in issue_hours:
|
||||
project = str(issue.project)
|
||||
version = get_version(issue)
|
||||
status = str(issue.status)
|
||||
|
||||
display_project = project if project != prev_project else ""
|
||||
display_version = version if (project != prev_project or version != prev_version) else ""
|
||||
lines.append(f"{display_project} | {display_version} | {issue.id}. {issue.subject} | {status} | {hours_to_human(hours)}")
|
||||
|
||||
prev_project = project
|
||||
prev_version = version
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_table(issue_hours: List[Tuple[Issue, float]]) -> str:
|
||||
from tabulate import tabulate
|
||||
|
||||
rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']]
|
||||
prev_project = None
|
||||
prev_version = None
|
||||
|
||||
for issue, hours in issue_hours:
|
||||
project = str(issue.project)
|
||||
version = get_version(issue)
|
||||
status_en = str(issue.status)
|
||||
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
|
||||
|
||||
display_project = project if project != prev_project else ""
|
||||
display_version = version if (project != prev_project or version != prev_version) else ""
|
||||
|
||||
rows.append([
|
||||
display_project,
|
||||
display_version,
|
||||
f"{issue.id}. {issue.subject}",
|
||||
status_ru,
|
||||
hours_to_human(hours)
|
||||
])
|
||||
|
||||
prev_project = project
|
||||
prev_version = version
|
||||
|
||||
return tabulate(rows, headers="firstrow", tablefmt="fancy_grid")
|
||||
0
redmine_reporter/formatters/__init__.py
Normal file
0
redmine_reporter/formatters/__init__.py
Normal file
34
redmine_reporter/formatters/base.py
Normal file
34
redmine_reporter/formatters/base.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, List
|
||||
|
||||
from ..types import ReportRow
|
||||
|
||||
|
||||
class Formatter(ABC):
|
||||
"""
|
||||
Абстрактный базовый класс для всех форматтеров.
|
||||
Определяет общий интерфейс для форматирования отчета.
|
||||
|
||||
Контракт:
|
||||
- format() возвращает строку для текстовых форматтеров (CSV, HTML, Markdown, console)
|
||||
и объект OpenDocument для ODTFormatter.
|
||||
- save() сохраняет результат в файл; консольные форматтеры бросают NotImplementedError.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def format(self, rows: List[ReportRow]) -> Any:
|
||||
"""
|
||||
Форматирует список строк отчета в нужный формат.
|
||||
Для текстовых форматтеров возвращает str.
|
||||
Для ODTFormatter возвращает объект OpenDocument.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
"""
|
||||
Сохраняет отформатированный отчет в файл по указанному пути.
|
||||
Для форматтеров, которые не поддерживают сохранение (например, консольные),
|
||||
бросает NotImplementedError.
|
||||
"""
|
||||
pass
|
||||
49
redmine_reporter/formatters/console.py
Normal file
49
redmine_reporter/formatters/console.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from typing import List
|
||||
|
||||
from tabulate import tabulate
|
||||
|
||||
from ..types import ReportRow
|
||||
from .base import Formatter
|
||||
|
||||
|
||||
class TableFormatter(Formatter):
|
||||
"""Форматтер для вывода красивой таблицы в консоль."""
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
|
||||
for r in rows:
|
||||
time_text = r["time_text"].replace("\n", " / ")
|
||||
table_rows.append(
|
||||
[
|
||||
r["display_project"],
|
||||
r["display_version"],
|
||||
f"{r['issue_id']}. {r['subject']}",
|
||||
r["status_ru"],
|
||||
time_text,
|
||||
]
|
||||
)
|
||||
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
# Консольные форматтеры не умеют сохранять в файл напрямую.
|
||||
# Это делается в CLI.
|
||||
raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.")
|
||||
|
||||
|
||||
class CompactFormatter(Formatter):
|
||||
"""Форматтер для компактного вывода в консоль."""
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
lines = []
|
||||
for r in rows:
|
||||
time_text = r["time_text"].replace("\n", " / ")
|
||||
lines.append(
|
||||
f"{r['display_project']} | {r['display_version']} | "
|
||||
f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {time_text}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
# Консольные форматтеры не умеют сохранять в файл напрямую.
|
||||
# Это делается в CLI.
|
||||
raise NotImplementedError("CompactFormatter не поддерживает сохранение в файл.")
|
||||
46
redmine_reporter/formatters/csv.py
Normal file
46
redmine_reporter/formatters/csv.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import csv
|
||||
import io
|
||||
from typing import List
|
||||
|
||||
from ..types import ReportRow
|
||||
from .base import Formatter
|
||||
|
||||
|
||||
class CSVFormatter(Formatter):
|
||||
"""Форматтер для экспорта в CSV.
|
||||
|
||||
Использует полные значения project/version (а не display-значения с пустыми
|
||||
ячейками для групп). Каждая строка CSV самодостаточна — это корректно для
|
||||
табличного формата (#31). Файл сохраняется в UTF-8 с BOM (utf-8-sig) для
|
||||
корректного отображения кириллицы в Microsoft Excel (#26).
|
||||
"""
|
||||
|
||||
def __init__(self, no_time: bool = False, **_kwargs):
|
||||
super().__init__()
|
||||
self.no_time = no_time
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, dialect="excel")
|
||||
headers = ["Project", "Version", "Issue ID", "Subject", "Status"]
|
||||
if not self.no_time:
|
||||
headers.append("Spent Time")
|
||||
writer.writerow(headers)
|
||||
for r in rows:
|
||||
time_text = r["time_text"].replace("\n", " / ")
|
||||
data = [
|
||||
r["project"],
|
||||
r["version"],
|
||||
r["issue_id"],
|
||||
r["subject"],
|
||||
r["status_ru"],
|
||||
]
|
||||
if not self.no_time:
|
||||
data.append(time_text)
|
||||
writer.writerow(data)
|
||||
return output.getvalue()
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
content = self.format(rows)
|
||||
with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
|
||||
f.write(content)
|
||||
61
redmine_reporter/formatters/factory.py
Normal file
61
redmine_reporter/formatters/factory.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from typing import Dict, Optional, Type
|
||||
|
||||
from .base import Formatter
|
||||
from .console import CompactFormatter, TableFormatter
|
||||
from .csv import CSVFormatter
|
||||
from .html import HTMLFormatter
|
||||
from .json import JSONFormatter
|
||||
from .markdown import MarkdownFormatter
|
||||
from .xlsx import XLSXFormatter
|
||||
|
||||
# Словарь для сопоставления расширений файлов с классами форматтеров.
|
||||
# ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
|
||||
# odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
|
||||
FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
||||
".csv": CSVFormatter,
|
||||
".json": JSONFormatter,
|
||||
".md": MarkdownFormatter,
|
||||
".html": HTMLFormatter,
|
||||
".xlsx": XLSXFormatter,
|
||||
}
|
||||
|
||||
|
||||
# Словарь для сопоставления типа вывода (консоль) с классами форматтеров
|
||||
CONSOLE_FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
||||
"table": TableFormatter,
|
||||
"compact": CompactFormatter,
|
||||
}
|
||||
|
||||
|
||||
def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]:
|
||||
"""
|
||||
Возвращает экземпляр форматтера по расширению файла.
|
||||
Ключевые аргументы (**kwargs) передаются в конструктор форматтера.
|
||||
Возвращает None для .odt, если odfpy не установлен.
|
||||
"""
|
||||
ext = extension.lower()
|
||||
|
||||
formatter_class = FORMATTER_MAP.get(ext)
|
||||
if formatter_class:
|
||||
return formatter_class(**kwargs)
|
||||
|
||||
# ODT требует odfpy — ленивый импорт, чтобы отсутствие зависимости
|
||||
# не ломало загрузку модуля и другие форматтеры.
|
||||
if ext == ".odt":
|
||||
try:
|
||||
from .odt import ODTFormatter
|
||||
except ImportError:
|
||||
return None
|
||||
return ODTFormatter(**kwargs)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_console_formatter(formatter_type: str) -> Optional[Formatter]:
|
||||
"""
|
||||
Возвращает экземпляр консольного форматтера по его типу.
|
||||
"""
|
||||
formatter_class = CONSOLE_FORMATTER_MAP.get(formatter_type.lower())
|
||||
if formatter_class:
|
||||
return formatter_class()
|
||||
return None
|
||||
85
redmine_reporter/formatters/html.py
Normal file
85
redmine_reporter/formatters/html.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from html import escape
|
||||
from typing import List
|
||||
|
||||
from ..report_builder import group_rows_by_project_and_version
|
||||
from ..types import ReportRow
|
||||
from .base import Formatter
|
||||
|
||||
|
||||
class HTMLFormatter(Formatter):
|
||||
"""Форматтер для экспорта отчёта в HTML."""
|
||||
|
||||
def __init__(self, **_kwargs):
|
||||
super().__init__()
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
projects = group_rows_by_project_and_version(rows)
|
||||
|
||||
lines = [
|
||||
"<!DOCTYPE html>",
|
||||
'<html lang="ru">',
|
||||
"<head>",
|
||||
' <meta charset="utf-8">',
|
||||
"</head>",
|
||||
"<body>",
|
||||
'<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse; font-family: Arial, sans-serif;">',
|
||||
" <thead>",
|
||||
" <tr>",
|
||||
" <th>Наименование Проекта</th>",
|
||||
" <th>Номер версии*</th>",
|
||||
" <th>Задача</th>",
|
||||
" <th>Статус Готовность*</th>",
|
||||
" <th>Затрачено за отчетный период</th>",
|
||||
" </tr>",
|
||||
" </thead>",
|
||||
" <tbody>",
|
||||
]
|
||||
|
||||
for project, versions in projects.items():
|
||||
project_text = escape(project)
|
||||
total_project_rows = sum(len(tasks) for tasks in versions.values())
|
||||
first_version_in_project = True
|
||||
|
||||
for version, task_rows in versions.items():
|
||||
version_text = escape(version)
|
||||
row_span_version = len(task_rows)
|
||||
first_row_in_version = True
|
||||
|
||||
for r in task_rows:
|
||||
task_cell = escape(f"{r['issue_id']}. {r['subject']}")
|
||||
status_text = escape(r["status_ru"])
|
||||
time_text = escape(r["time_text"]).replace("\n", "<br>")
|
||||
lines.append(" <tr>")
|
||||
|
||||
# Ячейка "Проект" - только в первой строке проекта
|
||||
if first_version_in_project and first_row_in_version:
|
||||
lines.append(
|
||||
f' <td rowspan="{total_project_rows}" style="vertical-align: top;">{project_text}</td>'
|
||||
)
|
||||
|
||||
# Ячейка "Версия" - только в первой строке версии
|
||||
if first_row_in_version:
|
||||
lines.append(
|
||||
f' <td rowspan="{row_span_version}" style="vertical-align: top;">{version_text}</td>'
|
||||
)
|
||||
first_row_in_version = False
|
||||
|
||||
# Остальные колонки
|
||||
lines.append(f" <td>{task_cell}</td>")
|
||||
lines.append(f" <td>{status_text}</td>")
|
||||
lines.append(f" <td>{time_text}</td>")
|
||||
|
||||
lines.append(" </tr>")
|
||||
|
||||
first_version_in_project = False
|
||||
|
||||
lines.append(" </tbody>")
|
||||
lines.append("</table>")
|
||||
lines.append("</body>")
|
||||
lines.append("</html>")
|
||||
return "\n".join(lines)
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
content = self.format(rows)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
34
redmine_reporter/formatters/json.py
Normal file
34
redmine_reporter/formatters/json.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
from ..types import ReportRow
|
||||
from .base import Formatter
|
||||
|
||||
|
||||
class JSONFormatter(Formatter):
|
||||
"""Форматтер для экспорта отчёта в JSON."""
|
||||
|
||||
def __init__(self, **_kwargs):
|
||||
super().__init__()
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
data = []
|
||||
for r in rows:
|
||||
item = {
|
||||
"project": r["project"],
|
||||
"version": r["version"],
|
||||
"issue_id": r["issue_id"],
|
||||
"subject": r["subject"],
|
||||
"status": r["status_ru"],
|
||||
"time": r["time_text"],
|
||||
}
|
||||
activities = r.get("activities")
|
||||
if activities:
|
||||
item["activities"] = activities
|
||||
data.append(item)
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
content = self.format(rows)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
36
redmine_reporter/formatters/markdown.py
Normal file
36
redmine_reporter/formatters/markdown.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from typing import List
|
||||
|
||||
from ..types import ReportRow
|
||||
from .base import Formatter
|
||||
|
||||
|
||||
def _escape_markdown_table_cell(value: object) -> str:
|
||||
return str(value).replace("\\", "\\\\").replace("|", "\\|").replace("\n", "<br>")
|
||||
|
||||
|
||||
class MarkdownFormatter(Formatter):
|
||||
"""Форматтер для экспорта в Markdown."""
|
||||
|
||||
def __init__(self, **_kwargs):
|
||||
super().__init__()
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
lines = [
|
||||
"| Проект | Версия | Задача | Статус | Затрачено |",
|
||||
"|--------|--------|--------|--------|-----------|",
|
||||
]
|
||||
for r in rows:
|
||||
task_cell = _escape_markdown_table_cell(f"{r['issue_id']}. {r['subject']}")
|
||||
lines.append(
|
||||
f"| {_escape_markdown_table_cell(r['display_project'])} "
|
||||
f"| {_escape_markdown_table_cell(r['display_version'])} "
|
||||
f"| {task_cell} "
|
||||
f"| {_escape_markdown_table_cell(r['status_ru'])} "
|
||||
f"| {_escape_markdown_table_cell(r['time_text'])} |"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
content = self.format(rows)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
180
redmine_reporter/formatters/odt.py
Normal file
180
redmine_reporter/formatters/odt.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from importlib import resources
|
||||
from typing import List
|
||||
|
||||
from odf.opendocument import OpenDocument, load
|
||||
from odf.style import Style, TableCellProperties, TableColumnProperties
|
||||
from odf.table import CoveredTableCell, Table, TableCell, TableColumn, TableRow
|
||||
from odf.text import P
|
||||
|
||||
from ..report_builder import group_rows_by_project_and_version
|
||||
from ..types import ReportRow
|
||||
from ..utils import get_month_name_from_range
|
||||
from .base import Formatter
|
||||
|
||||
|
||||
class ODTFormatter(Formatter):
|
||||
"""Форматтер для экспорта в ODT."""
|
||||
|
||||
def __init__(
|
||||
self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs
|
||||
):
|
||||
"""
|
||||
Инициализирует форматтер с параметрами для шапки отчета.
|
||||
"""
|
||||
self.author = author
|
||||
self.from_date = from_date
|
||||
self.to_date = to_date
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> OpenDocument:
|
||||
"""
|
||||
Форматирует данные в объект OpenDocument.
|
||||
"""
|
||||
|
||||
with (
|
||||
resources.files("redmine_reporter")
|
||||
.joinpath("templates/template.odt")
|
||||
.open("rb") as f
|
||||
):
|
||||
doc = load(f)
|
||||
|
||||
# Удаляем все текстовые параграфы из шаблона, оставляя только
|
||||
# структурные элементы (forms, sequence-decls). Это защищает от
|
||||
# артефактов редактирования шаблона в LibreOffice (#42).
|
||||
for child in list(doc.text.childNodes):
|
||||
if child.tagName == "text:p":
|
||||
doc.text.removeChild(child)
|
||||
|
||||
para_style_name = "Standard"
|
||||
|
||||
# Заголовок
|
||||
month_name = get_month_name_from_range(self.from_date, self.to_date)
|
||||
if self.author:
|
||||
header_text = f"{self.author}. Отчет за месяц {month_name}."
|
||||
else:
|
||||
header_text = f"Отчет за месяц {month_name}."
|
||||
doc.text.addElement(P(stylename=para_style_name, text=header_text))
|
||||
doc.text.addElement(P(stylename=para_style_name, text=""))
|
||||
|
||||
# Стиль ячеек
|
||||
cell_style_name = "TableCellStyle"
|
||||
cell_style = Style(name=cell_style_name, family="table-cell")
|
||||
cell_props = TableCellProperties(
|
||||
padding="0.04in", border="0.05pt solid #000000"
|
||||
)
|
||||
cell_style.addElement(cell_props)
|
||||
doc.automaticstyles.addElement(cell_style)
|
||||
|
||||
# Таблица
|
||||
table = Table(name="Report")
|
||||
column_widths = ["1.56in", "1.63in", "3.93in", "1.56in", "1.43in"]
|
||||
for width in column_widths:
|
||||
col_style = Style(name=f"col_{width}", family="table-column")
|
||||
col_props = TableColumnProperties(columnwidth=width)
|
||||
col_style.addElement(col_props)
|
||||
doc.automaticstyles.addElement(col_style)
|
||||
table.addElement(TableColumn(stylename=col_style))
|
||||
|
||||
# Заголовки
|
||||
header_row = TableRow()
|
||||
for text in [
|
||||
"Наименование Проекта",
|
||||
"Номер версии*",
|
||||
"Задача",
|
||||
"Статус Готовность*",
|
||||
"Затрачено за отчетный период",
|
||||
]:
|
||||
cell = TableCell(stylename=cell_style_name)
|
||||
cell.addElement(P(stylename=para_style_name, text=text))
|
||||
header_row.addElement(cell)
|
||||
table.addElement(header_row)
|
||||
|
||||
projects = group_rows_by_project_and_version(rows)
|
||||
|
||||
# Данные с двухуровневой группировкой и объединением ячеек
|
||||
for project, versions in projects.items():
|
||||
total_project_rows = sum(
|
||||
len(rows_for_version) for rows_for_version in versions.values()
|
||||
)
|
||||
first_version_in_project = True
|
||||
|
||||
for version, rows_for_version in versions.items():
|
||||
row_span_version = len(rows_for_version)
|
||||
first_row_in_version = True
|
||||
|
||||
for r in rows_for_version:
|
||||
row = TableRow()
|
||||
|
||||
# Ячейка "Проект" - только в первой строке всего проекта,
|
||||
# в остальных — covered-cell для валидности ODF (#13)
|
||||
if first_version_in_project and first_row_in_version:
|
||||
cell_project = TableCell(stylename=cell_style_name)
|
||||
cell_project.setAttribute(
|
||||
"numberrowsspanned", str(total_project_rows)
|
||||
)
|
||||
p = P(stylename=para_style_name, text=project)
|
||||
cell_project.addElement(p)
|
||||
row.addElement(cell_project)
|
||||
else:
|
||||
row.addElement(CoveredTableCell())
|
||||
|
||||
# Ячейка "Версия" - только в первой строке каждой версии,
|
||||
# в остальных — covered-cell для валидности ODF (#13)
|
||||
if first_row_in_version:
|
||||
cell_version = TableCell(stylename=cell_style_name)
|
||||
cell_version.setAttribute(
|
||||
"numberrowsspanned", str(row_span_version)
|
||||
)
|
||||
p = P(stylename=para_style_name, text=version)
|
||||
cell_version.addElement(p)
|
||||
row.addElement(cell_version)
|
||||
first_row_in_version = False
|
||||
else:
|
||||
row.addElement(CoveredTableCell())
|
||||
|
||||
# Остальные колонки
|
||||
task_cell = TableCell(stylename=cell_style_name)
|
||||
task_text = f"{r['issue_id']}. {r['subject']}"
|
||||
p = P(stylename=para_style_name, text=task_text)
|
||||
task_cell.addElement(p)
|
||||
row.addElement(task_cell)
|
||||
|
||||
status_cell = TableCell(stylename=cell_style_name)
|
||||
p = P(stylename=para_style_name, text=r["status_ru"])
|
||||
status_cell.addElement(p)
|
||||
row.addElement(status_cell)
|
||||
|
||||
time_cell = TableCell(stylename=cell_style_name)
|
||||
time_lines = r["time_text"].split("\n")
|
||||
for i, line in enumerate(time_lines):
|
||||
p = P(stylename=para_style_name, text=line)
|
||||
time_cell.addElement(p)
|
||||
if i < len(time_lines) - 1:
|
||||
time_cell.addElement(P(stylename=para_style_name, text=""))
|
||||
row.addElement(time_cell)
|
||||
|
||||
table.addElement(row)
|
||||
|
||||
first_version_in_project = False
|
||||
|
||||
doc.text.addElement(table)
|
||||
doc.text.addElement(P(stylename=para_style_name, text=""))
|
||||
|
||||
# Справка
|
||||
for line in [
|
||||
"«Наименование Проекта» - Имя собственное устройства или программного обеспечения.",
|
||||
"«Номер версии» - Версия в проекте. Опциональное поле.",
|
||||
"«Задача» - Номер по Redmine и формулировка.",
|
||||
"«Статус» - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена.",
|
||||
"«Готовность» – Опциональное поле в процентах.",
|
||||
"«Затрачено за отчетный период» - в днях или часах.",
|
||||
]:
|
||||
doc.text.addElement(P(stylename=para_style_name, text=line))
|
||||
|
||||
return doc
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
"""
|
||||
Сохраняет сформированный документ в файл.
|
||||
"""
|
||||
doc = self.format(rows)
|
||||
doc.save(output_path)
|
||||
242
redmine_reporter/formatters/xlsx.py
Normal file
242
redmine_reporter/formatters/xlsx.py
Normal file
@@ -0,0 +1,242 @@
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
|
||||
from ..report_builder import group_rows_by_project_and_version
|
||||
from ..types import ReportRow
|
||||
from ..utils import hours_to_human
|
||||
from .base import Formatter
|
||||
|
||||
|
||||
class XLSXFormatter(Formatter):
|
||||
"""Форматтер для экспорта отчёта в Excel (.xlsx).
|
||||
|
||||
Использует группировку по проекту и версии: объединяет ячейки,
|
||||
добавляет итоги по группам, закрепляет заголовок, включает автофильтр
|
||||
и числовой столбец с часами для удобного суммирования.
|
||||
"""
|
||||
|
||||
_HEADER_FILL = PatternFill(
|
||||
start_color="D9E1F2", end_color="D9E1F2", fill_type="solid"
|
||||
)
|
||||
_TOTAL_FILL = PatternFill(
|
||||
start_color="FFF2CC", end_color="FFF2CC", fill_type="solid"
|
||||
)
|
||||
_BORDER = Border(
|
||||
left=Side(style="thin"),
|
||||
right=Side(style="thin"),
|
||||
top=Side(style="thin"),
|
||||
bottom=Side(style="thin"),
|
||||
)
|
||||
|
||||
def __init__(self, no_time: bool = False, **_kwargs):
|
||||
super().__init__()
|
||||
self.no_time = no_time
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> Workbook:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Report"
|
||||
|
||||
headers = [
|
||||
"Project",
|
||||
"Version",
|
||||
"Issue ID",
|
||||
"Subject",
|
||||
"Status",
|
||||
"Hours",
|
||||
"Spent Time",
|
||||
]
|
||||
ws.append(headers)
|
||||
self._style_header_row(ws, headers)
|
||||
|
||||
grouped = group_rows_by_project_and_version(rows)
|
||||
|
||||
current_row = 2
|
||||
project_ranges: List[Tuple[int, int]] = []
|
||||
version_ranges: List[Tuple[int, int]] = []
|
||||
project_totals: Dict[str, float] = {}
|
||||
|
||||
for project, versions in grouped.items():
|
||||
project_start_row = current_row
|
||||
|
||||
for version, task_rows in versions.items():
|
||||
version_start_row = current_row
|
||||
|
||||
for r in task_rows:
|
||||
hours = "" if self.no_time else r.get("hours", 0.0)
|
||||
time_text = "" if self.no_time else r["time_text"]
|
||||
ws.append(
|
||||
[
|
||||
project,
|
||||
version,
|
||||
r["issue_id"],
|
||||
r["subject"],
|
||||
r["status_ru"],
|
||||
hours,
|
||||
time_text,
|
||||
]
|
||||
)
|
||||
self._style_data_row(ws, current_row)
|
||||
current_row += 1
|
||||
|
||||
if not self.no_time:
|
||||
version_hours = sum(r.get("hours", 0.0) for r in task_rows)
|
||||
ws.append(
|
||||
[
|
||||
"",
|
||||
f"Total {version}",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
version_hours,
|
||||
hours_to_human(version_hours),
|
||||
]
|
||||
)
|
||||
self._style_total_row(ws, current_row, bold=False)
|
||||
ws.merge_cells(
|
||||
start_row=current_row,
|
||||
start_column=2,
|
||||
end_row=current_row,
|
||||
end_column=5,
|
||||
)
|
||||
current_row += 1
|
||||
|
||||
version_end_row = current_row - 1
|
||||
if version_end_row > version_start_row:
|
||||
version_ranges.append((version_start_row, version_end_row))
|
||||
|
||||
if not self.no_time:
|
||||
project_hours = sum(
|
||||
sum(r.get("hours", 0.0) for r in task_rows)
|
||||
for task_rows in versions.values()
|
||||
)
|
||||
ws.append(
|
||||
[
|
||||
f"Total {project}",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
project_hours,
|
||||
hours_to_human(project_hours),
|
||||
]
|
||||
)
|
||||
self._style_total_row(ws, current_row, bold=True)
|
||||
ws.merge_cells(
|
||||
start_row=current_row,
|
||||
start_column=1,
|
||||
end_row=current_row,
|
||||
end_column=5,
|
||||
)
|
||||
current_row += 1
|
||||
project_totals[project] = project_hours
|
||||
|
||||
project_end_row = current_row - 1
|
||||
if project_end_row > project_start_row:
|
||||
project_ranges.append((project_start_row, project_end_row))
|
||||
|
||||
if not self.no_time and project_totals:
|
||||
total_hours = sum(project_totals.values())
|
||||
ws.append(
|
||||
[
|
||||
"Total",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
total_hours,
|
||||
hours_to_human(total_hours),
|
||||
]
|
||||
)
|
||||
self._style_total_row(ws, current_row, bold=True)
|
||||
ws.merge_cells(
|
||||
start_row=current_row, start_column=1, end_row=current_row, end_column=5
|
||||
)
|
||||
|
||||
for start, end in project_ranges:
|
||||
ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1)
|
||||
cell = ws.cell(row=start, column=1)
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
|
||||
for start, end in version_ranges:
|
||||
ws.merge_cells(start_row=start, start_column=2, end_row=end, end_column=2)
|
||||
cell = ws.cell(row=start, column=2)
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
|
||||
self._apply_column_widths(ws)
|
||||
if not self.no_time:
|
||||
self._apply_number_format(ws)
|
||||
self._apply_auto_filter(ws, ws.max_row)
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
return wb
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
wb = self.format(rows)
|
||||
wb.save(output_path)
|
||||
|
||||
def _style_header_row(self, ws: Worksheet, headers: List[str]) -> None:
|
||||
for col_idx, _ in enumerate(headers, start=1):
|
||||
cell = ws.cell(row=1, column=col_idx)
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = self._HEADER_FILL
|
||||
cell.border = self._BORDER
|
||||
cell.alignment = Alignment(
|
||||
horizontal="center", vertical="center", wrap_text=True
|
||||
)
|
||||
|
||||
def _style_data_row(self, ws: Worksheet, row: int) -> None:
|
||||
for col_idx in range(1, 8):
|
||||
cell = ws.cell(row=row, column=col_idx)
|
||||
cell.border = self._BORDER
|
||||
if col_idx in (1, 2):
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
elif col_idx == 4:
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
else:
|
||||
cell.alignment = Alignment(vertical="top")
|
||||
|
||||
def _style_total_row(self, ws: Worksheet, row: int, bold: bool) -> None:
|
||||
for col_idx in range(1, 8):
|
||||
cell = ws.cell(row=row, column=col_idx)
|
||||
cell.border = self._BORDER
|
||||
cell.fill = self._TOTAL_FILL
|
||||
cell.font = Font(bold=bold)
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
|
||||
def _apply_column_widths(self, ws: Worksheet) -> None:
|
||||
# Минимальные ширины по умолчанию
|
||||
widths: Dict[int, float] = {
|
||||
1: 18.0,
|
||||
2: 16.0,
|
||||
3: 12.0,
|
||||
4: 45.0,
|
||||
5: 14.0,
|
||||
6: 10.0,
|
||||
7: 14.0,
|
||||
}
|
||||
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
||||
for col_idx, cell in enumerate(row, start=1):
|
||||
if cell.value is None:
|
||||
continue
|
||||
text = str(cell.value)
|
||||
# Оценочная ширина: примерно 1.1 символа на единицу ширины Excel
|
||||
estimated = len(text) * 1.1 + 2
|
||||
widths[col_idx] = max(widths[col_idx], min(estimated, 80))
|
||||
|
||||
for col_idx, width in widths.items():
|
||||
ws.column_dimensions[get_column_letter(col_idx)].width = width
|
||||
|
||||
def _apply_number_format(self, ws: Worksheet) -> None:
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=6, max_col=6):
|
||||
for cell in row:
|
||||
if isinstance(cell.value, (int, float)):
|
||||
cell.number_format = "0.00"
|
||||
|
||||
def _apply_auto_filter(self, ws: Worksheet, max_row: int) -> None:
|
||||
ws.auto_filter.ref = f"A1:G{max_row}"
|
||||
152
redmine_reporter/mailer.py
Normal file
152
redmine_reporter/mailer.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Отправка сгенерированного отчёта по email через SMTP."""
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Dict, List
|
||||
|
||||
from .client import RedmineAPIError
|
||||
from .config import EmailConfig
|
||||
from .types import ReportRow
|
||||
|
||||
SMTP_TIMEOUT = 30
|
||||
|
||||
MIME_TYPES: Dict[str, str] = {
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".odt": "application/vnd.oasis.opendocument.text",
|
||||
".csv": "text/csv",
|
||||
".html": "text/html",
|
||||
".json": "application/json",
|
||||
".md": "text/markdown",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_mime_type(file_path: str) -> str:
|
||||
"""Определяет MIME-тип по расширению файла."""
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
return MIME_TYPES.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
def _utf8_text_part(text: str, subtype: str = "plain") -> MIMEText:
|
||||
"""Создаёт текстовую MIME-часть с 8bit UTF-8 телом.
|
||||
|
||||
Кодировка применяется точечно к части письма, без глобальной мутации
|
||||
реестра email.charset: payload хранится как UTF-8 (surrogateescape),
|
||||
а Content-Transfer-Encoding выставляется в 8bit, чтобы не-ASCII текст
|
||||
(например, русский) передавался литерально, а не в base64.
|
||||
"""
|
||||
part = MIMEText("", subtype, "utf-8")
|
||||
part.set_payload(text.encode("utf-8").decode("ascii", "surrogateescape"))
|
||||
part.replace_header("Content-Transfer-Encoding", "8bit")
|
||||
return part
|
||||
|
||||
|
||||
def _build_html_body(rows: List[ReportRow]) -> str:
|
||||
"""Генерирует HTML-версию тела письма через HTMLFormatter."""
|
||||
from .formatters.html import HTMLFormatter
|
||||
|
||||
formatter = HTMLFormatter()
|
||||
return formatter.format(rows)
|
||||
|
||||
|
||||
def build_message(
|
||||
email_config: EmailConfig,
|
||||
file_path: str,
|
||||
author: str,
|
||||
period: str,
|
||||
rows: List[ReportRow],
|
||||
) -> MIMEMultipart:
|
||||
"""Формирует MIME-письмо с подстановками, телами и вложением."""
|
||||
subject = email_config.subject.replace("{author}", author).replace(
|
||||
"{period}", period
|
||||
)
|
||||
body = email_config.body_text.replace("{author}", author).replace(
|
||||
"{period}", period
|
||||
)
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = email_config.from_
|
||||
msg["To"] = ", ".join(email_config.to)
|
||||
if email_config.cc:
|
||||
msg["Cc"] = ", ".join(email_config.cc)
|
||||
|
||||
# Тела письма: plain-text всегда, HTML по флагу
|
||||
body_container = MIMEMultipart("alternative")
|
||||
body_container.attach(_utf8_text_part(body))
|
||||
|
||||
if email_config.html:
|
||||
html_body = _build_html_body(rows)
|
||||
body_container.attach(_utf8_text_part(html_body, "html"))
|
||||
|
||||
msg.attach(body_container)
|
||||
|
||||
if email_config.attach:
|
||||
try:
|
||||
with open(file_path, "rb") as fh:
|
||||
attachment = MIMEApplication(fh.read())
|
||||
except OSError:
|
||||
raise RedmineAPIError(
|
||||
f"Не удалось прочитать файл отчёта: {file_path}"
|
||||
) from None
|
||||
attachment.add_header(
|
||||
"Content-Disposition",
|
||||
"attachment",
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
mime_type = _resolve_mime_type(file_path)
|
||||
attachment.set_type(mime_type)
|
||||
msg.attach(attachment)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def send_report(
|
||||
email_config: EmailConfig,
|
||||
file_path: str,
|
||||
author: str,
|
||||
period: str,
|
||||
rows: List[ReportRow],
|
||||
) -> None:
|
||||
"""Отправляет сгенерированный отчёт по email через SMTP.
|
||||
|
||||
Args:
|
||||
email_config: Настройки SMTP и письма.
|
||||
file_path: Путь к файлу отчёта для вложения.
|
||||
author: Имя автора (для подстановки в тему/тело).
|
||||
period: Строка периода (для подстановки в тему/тело).
|
||||
rows: Строки отчёта (для HTML-версии тела письма).
|
||||
|
||||
Raises:
|
||||
RedmineAPIError: При любой ошибке соединения или отправки.
|
||||
"""
|
||||
smtp_cfg = email_config.smtp
|
||||
msg = build_message(email_config, file_path, author, period, rows)
|
||||
all_recipients = (
|
||||
list(email_config.to) + list(email_config.cc) + list(email_config.bcc)
|
||||
)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=SMTP_TIMEOUT) as server:
|
||||
if smtp_cfg.tls:
|
||||
server.starttls()
|
||||
if smtp_cfg.user:
|
||||
server.login(smtp_cfg.user, smtp_cfg.password)
|
||||
|
||||
server.send_message(
|
||||
msg, from_addr=email_config.from_, to_addrs=all_recipients
|
||||
)
|
||||
except smtplib.SMTPAuthenticationError:
|
||||
raise RedmineAPIError(
|
||||
"Ошибка аутентификации SMTP. Проверьте логин и пароль."
|
||||
) from None
|
||||
except TimeoutError:
|
||||
raise RedmineAPIError("Таймаут соединения с SMTP-сервером.") from None
|
||||
except smtplib.SMTPException as exc:
|
||||
raise RedmineAPIError(f"Ошибка отправки письма: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise RedmineAPIError(
|
||||
f"Не удалось подключиться к SMTP-серверу {smtp_cfg.host}:{smtp_cfg.port}"
|
||||
) from exc
|
||||
168
redmine_reporter/report_builder.py
Normal file
168
redmine_reporter/report_builder.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from typing import Dict, List, Optional, Tuple, cast
|
||||
|
||||
from redminelib.resources import Issue
|
||||
|
||||
from .types import ReportRow
|
||||
from .utils import get_version, hours_to_human
|
||||
|
||||
STATUS_TRANSLATION = {
|
||||
"New": "В работе",
|
||||
"In Progress": "В работе",
|
||||
"Feedback": "В работе",
|
||||
"Re-opened": "В работе",
|
||||
"Code Review": "Решена",
|
||||
"Wait Release": "Закрыто",
|
||||
"Pending": "Ожидание",
|
||||
"Resolved": "Решена",
|
||||
"Testing": "Решена",
|
||||
"Confirming": "Ожидание",
|
||||
"Closed": "Закрыто",
|
||||
"Rejected": "Закрыто",
|
||||
"Frozen": "Ожидание",
|
||||
}
|
||||
|
||||
|
||||
def _format_activities(activities: Dict[str, float]) -> str:
|
||||
"""Форматирует разбивку по активностям в многострочный текст."""
|
||||
if not activities:
|
||||
return ""
|
||||
# Сортируем по убыванию часов, затем по алфавиту для стабильности
|
||||
items = sorted(activities.items(), key=lambda x: (-x[1], x[0]))
|
||||
return "\n".join(f"{hours_to_human(hours)} {name}" for name, hours in items)
|
||||
|
||||
|
||||
def build_grouped_report(
|
||||
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]],
|
||||
fill_time: bool = True,
|
||||
by_activity: bool = False,
|
||||
status_translation: Optional[Dict[str, str]] = None,
|
||||
) -> List[ReportRow]:
|
||||
"""
|
||||
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
||||
с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений).
|
||||
|
||||
Предусловие: issue_hours должен быть отсортирован по (project, version).
|
||||
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
|
||||
|
||||
status_translation: перевод статусов; None — встроенный STATUS_TRANSLATION.
|
||||
Переданный словарь ЗАМЕНЯЕТ встроенный полностью (без merge): статус,
|
||||
отсутствующий в нём, выводится как есть (passthrough); пустой dict —
|
||||
все статусы выводятся как есть.
|
||||
"""
|
||||
|
||||
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
||||
issue_hours = sorted(
|
||||
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
|
||||
)
|
||||
|
||||
translation = (
|
||||
status_translation if status_translation is not None else STATUS_TRANSLATION
|
||||
)
|
||||
|
||||
rows: List[ReportRow] = []
|
||||
prev_project: str = ""
|
||||
prev_version: str = ""
|
||||
|
||||
for issue, hours, *rest in issue_hours:
|
||||
activities: Optional[Dict[str, float]] = rest[0] if rest else None
|
||||
project = str(issue.project)
|
||||
version = get_version(issue)
|
||||
status_en = str(issue.status)
|
||||
status_ru = translation.get(status_en, status_en)
|
||||
|
||||
if fill_time:
|
||||
if by_activity and activities:
|
||||
time_text = _format_activities(activities)
|
||||
else:
|
||||
time_text = hours_to_human(hours)
|
||||
else:
|
||||
time_text = ""
|
||||
|
||||
display_project = project if project != prev_project else ""
|
||||
display_version = (
|
||||
version if (project != prev_project or version != prev_version) else ""
|
||||
)
|
||||
|
||||
rows.append(
|
||||
cast(
|
||||
ReportRow,
|
||||
{
|
||||
"project": project,
|
||||
"version": version,
|
||||
"display_project": display_project,
|
||||
"display_version": display_version,
|
||||
"issue_id": issue.id,
|
||||
"subject": issue.subject,
|
||||
"status_ru": status_ru,
|
||||
"time_text": time_text,
|
||||
"hours": round(hours, 2),
|
||||
"activities": activities,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
prev_project = project
|
||||
prev_version = version
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def calculate_summary(
|
||||
rows: List[ReportRow],
|
||||
by_activity: bool = False,
|
||||
) -> Dict[str, float]:
|
||||
"""Возвращает сводку: общее время, время по проектам, версиям и активностям."""
|
||||
total = 0.0
|
||||
by_project: Dict[str, float] = {}
|
||||
by_project_version: Dict[str, float] = {}
|
||||
by_activity_name: Dict[str, float] = {}
|
||||
|
||||
for r in rows:
|
||||
hours = r.get("hours", 0.0)
|
||||
total += hours
|
||||
by_project[r["project"]] = by_project.get(r["project"], 0.0) + hours
|
||||
key = f"{r['project']}::{r['version']}"
|
||||
by_project_version[key] = by_project_version.get(key, 0.0) + hours
|
||||
|
||||
if by_activity:
|
||||
activities = r.get("activities")
|
||||
if activities:
|
||||
for name, act_hours in activities.items():
|
||||
by_activity_name[name] = by_activity_name.get(name, 0.0) + act_hours
|
||||
|
||||
result: Dict[str, float] = {
|
||||
"total": round(total, 2),
|
||||
**{f"project:{k}": round(v, 2) for k, v in by_project.items()},
|
||||
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
|
||||
}
|
||||
if by_activity:
|
||||
result.update(
|
||||
{f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def group_rows_by_project_and_version(
|
||||
rows: List[ReportRow],
|
||||
) -> Dict[str, Dict[str, List[ReportRow]]]:
|
||||
"""
|
||||
Группирует плоский список строк отчёта в иерархию project → version → [rows].
|
||||
|
||||
Предполагается, что rows уже отсортирован (build_grouped_report это гарантирует).
|
||||
Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+).
|
||||
Используется форматтерами HTML и ODT для объединения ячеек.
|
||||
"""
|
||||
projects: Dict[str, Dict[str, List[ReportRow]]] = {}
|
||||
for r in rows:
|
||||
project = r["project"]
|
||||
version = r["version"]
|
||||
|
||||
if project not in projects:
|
||||
projects[project] = {}
|
||||
if version not in projects[project]:
|
||||
projects[project][version] = []
|
||||
|
||||
projects[project][version].append(r)
|
||||
|
||||
return projects
|
||||
BIN
redmine_reporter/templates/template.odt
Normal file
BIN
redmine_reporter/templates/template.odt
Normal file
Binary file not shown.
21
redmine_reporter/types.py
Normal file
21
redmine_reporter/types.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from typing import Dict, Optional, TypedDict
|
||||
|
||||
|
||||
class ReportRowBase(TypedDict):
|
||||
"""Строка итогового отчёта."""
|
||||
|
||||
project: str
|
||||
version: str
|
||||
display_project: str
|
||||
display_version: str
|
||||
issue_id: int
|
||||
subject: str
|
||||
status_ru: str
|
||||
time_text: str
|
||||
hours: float
|
||||
|
||||
|
||||
class ReportRow(ReportRowBase, total=False):
|
||||
"""Строка итогового отчёта с опциональной разбивкой по активностям."""
|
||||
|
||||
activities: Optional[Dict[str, float]]
|
||||
@@ -1,2 +1,56 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def get_month_name_from_range(from_date: str, to_date: str) -> str:
|
||||
"""Определяет название месяца по диапазону дат"""
|
||||
|
||||
try:
|
||||
end = datetime.strptime(to_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return "Январь"
|
||||
|
||||
months = [
|
||||
"",
|
||||
"Январь",
|
||||
"Февраль",
|
||||
"Март",
|
||||
"Апрель",
|
||||
"Май",
|
||||
"Июнь",
|
||||
"Июль",
|
||||
"Август",
|
||||
"Сентябрь",
|
||||
"Октябрь",
|
||||
"Ноябрь",
|
||||
"Декабрь",
|
||||
]
|
||||
|
||||
return months[end.month]
|
||||
|
||||
|
||||
def get_version(issue) -> str:
|
||||
return str(getattr(issue, 'fixed_version', '<N/A>'))
|
||||
"""Возвращает версию задачи или '<N/A>', если не задана."""
|
||||
version = getattr(issue, "fixed_version", None)
|
||||
if version is None:
|
||||
return "<N/A>"
|
||||
name = getattr(version, "name", None)
|
||||
return str(name) if name else str(version)
|
||||
|
||||
|
||||
def hours_to_human(hours: float) -> str:
|
||||
"""Преобразует часы в человекочитаемый формат: '2ч 30м'."""
|
||||
|
||||
if hours <= 0:
|
||||
return "0ч"
|
||||
|
||||
total_minutes = round(hours * 60)
|
||||
h = total_minutes // 60
|
||||
m = total_minutes % 60
|
||||
parts = []
|
||||
|
||||
if h:
|
||||
parts.append(f"{h}ч")
|
||||
if m:
|
||||
parts.append(f"{m}м")
|
||||
|
||||
return " ".join(parts) if parts else "0ч"
|
||||
|
||||
190
redmine_reporter/yaml_config.py
Normal file
190
redmine_reporter/yaml_config.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}")
|
||||
|
||||
|
||||
def resolve_env_vars(value: str) -> str:
|
||||
"""Replace ${VAR} patterns with environment variable values.
|
||||
|
||||
Non-recursive: if ${A} expands to a literal ${B}, ${B} is NOT resolved.
|
||||
Unknown variables are replaced with empty string and a warning is logged.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
def _replacer(match: re.Match) -> str:
|
||||
var_name = match.group(1)
|
||||
env_value = os.environ.get(var_name)
|
||||
if env_value is None:
|
||||
logger.warning(
|
||||
"Environment variable %s is not set, using empty string", var_name
|
||||
)
|
||||
return ""
|
||||
return env_value
|
||||
|
||||
return _ENV_VAR_RE.sub(_replacer, value)
|
||||
|
||||
|
||||
def ensure_config_dir(path: Path | None = None) -> Path:
|
||||
"""Create ~/.config/redmine-reporter/ with 0o700 permissions.
|
||||
|
||||
Returns the path to the config directory.
|
||||
No-op if the directory already exists.
|
||||
"""
|
||||
if path is None:
|
||||
path = Path.home() / ".config" / "redmine-reporter"
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def check_file_permissions(path: Path) -> list[str]:
|
||||
"""Check that a config file has safe permissions (0o600 or stricter).
|
||||
|
||||
Returns a list of warning messages. Empty list means no issues.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
if not path.exists():
|
||||
return warnings
|
||||
|
||||
mode = path.stat().st_mode & 0o777
|
||||
if mode > 0o600:
|
||||
warnings.append(
|
||||
f"Config file permissions are too open ({mode:04o}). "
|
||||
f"Expected 0600. Fix with: chmod 600 {path}"
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def expand_filename_template(
|
||||
template: str,
|
||||
*,
|
||||
author: str = "",
|
||||
from_date: str = "",
|
||||
to_date: str = "",
|
||||
ext: str = "",
|
||||
) -> str:
|
||||
"""Expand placeholders in a filename template.
|
||||
|
||||
Supported placeholders:
|
||||
{author} — author name
|
||||
{from} — start date (YYYY-MM-DD)
|
||||
{to} — end date (YYYY-MM-DD)
|
||||
{date} — end date formatted as DD_MM_YYYY
|
||||
{ext} — file extension without dot
|
||||
|
||||
Unknown placeholders are left as-is.
|
||||
"""
|
||||
date_dd_mm_yyyy = ""
|
||||
if to_date:
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
dt = datetime.strptime(to_date, "%Y-%m-%d")
|
||||
date_dd_mm_yyyy = dt.strftime("%d_%m_%Y")
|
||||
except ValueError:
|
||||
date_dd_mm_yyyy = to_date
|
||||
|
||||
replacements = {
|
||||
"author": author.replace(" ", "_"),
|
||||
"from": from_date,
|
||||
"to": to_date,
|
||||
"date": date_dd_mm_yyyy,
|
||||
"ext": ext,
|
||||
}
|
||||
|
||||
result = template
|
||||
for key, value in replacements.items():
|
||||
result = result.replace("{" + key + "}", value)
|
||||
return result
|
||||
|
||||
|
||||
KNOWN_FORMAT_NAMES = {"xlsx", "odt", "csv", "md", "html", "json"}
|
||||
|
||||
|
||||
def resolve_output_path(
|
||||
output_arg: str | None,
|
||||
*,
|
||||
output_dir: str = "",
|
||||
filename_template: str = "{author}_{from}_{to}.{ext}",
|
||||
default_format: str = "xlsx",
|
||||
author: str = "",
|
||||
from_date: str = "",
|
||||
to_date: str = "",
|
||||
) -> str | None:
|
||||
"""Resolve the final output path from --output argument and config settings.
|
||||
|
||||
Returns None if output_arg is None (meaning no file output, use console).
|
||||
|
||||
Resolution rules:
|
||||
- None → None (console output)
|
||||
- Bare format name (xlsx, odt, csv, md, html, json) → use template path
|
||||
- Path without extension → append .{default_format}
|
||||
- Path with known extension → use as-is
|
||||
- Path with unknown extension → use as-is
|
||||
"""
|
||||
if output_arg is None:
|
||||
return None
|
||||
|
||||
arg = output_arg.strip()
|
||||
|
||||
import os
|
||||
|
||||
if arg.lower() in KNOWN_FORMAT_NAMES:
|
||||
ext = arg.lower()
|
||||
resolved_dir = os.path.expanduser(output_dir) if output_dir else "."
|
||||
filename = expand_filename_template(
|
||||
filename_template,
|
||||
author=author,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
ext=ext,
|
||||
)
|
||||
return os.path.join(resolved_dir, filename)
|
||||
|
||||
_, ext = os.path.splitext(arg)
|
||||
if not ext:
|
||||
arg = f"{arg}.{default_format}"
|
||||
|
||||
return arg
|
||||
|
||||
|
||||
def save_period_to_config(
|
||||
config_path: str,
|
||||
from_str: str,
|
||||
to_str: str,
|
||||
precision: str,
|
||||
dynamic: bool,
|
||||
) -> None:
|
||||
"""Save committed period to YAML config file.
|
||||
|
||||
Writes period.last_used.from / period.last_used.to.
|
||||
When dynamic=False, also overwrites period.default_from / period.default_to.
|
||||
|
||||
Preserves all other sections unchanged. Creates config file if missing.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
path = Path(config_path)
|
||||
raw: dict = {}
|
||||
if path.exists():
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
raw = yaml.safe_load(fh) or {}
|
||||
|
||||
period = raw.setdefault("period", {})
|
||||
period["last_used"] = {"from": from_str, "to": to_str}
|
||||
|
||||
if not dynamic:
|
||||
period["default_from"] = from_str.split("T")[0] if "T" in from_str else from_str
|
||||
period["default_to"] = to_str.split("T")[0] if "T" in to_str else to_str
|
||||
|
||||
ensure_config_dir(path.parent)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
yaml.dump(
|
||||
raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
|
||||
)
|
||||
path.chmod(0o600)
|
||||
1664
tests/test_cli.py
1664
tests/test_cli.py
File diff suppressed because it is too large
Load Diff
1155
tests/test_client.py
Normal file
1155
tests/test_client.py
Normal file
File diff suppressed because it is too large
Load Diff
1075
tests/test_config.py
Normal file
1075
tests/test_config.py
Normal file
File diff suppressed because it is too large
Load Diff
603
tests/test_formatters.py
Normal file
603
tests/test_formatters.py
Normal file
@@ -0,0 +1,603 @@
|
||||
import io
|
||||
import json
|
||||
from typing import List
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from odf.opendocument import OpenDocument, OpenDocumentText
|
||||
|
||||
from redmine_reporter.formatters.console import CompactFormatter, TableFormatter
|
||||
from redmine_reporter.formatters.csv import CSVFormatter
|
||||
from redmine_reporter.formatters.factory import get_formatter_by_extension
|
||||
from redmine_reporter.formatters.html import HTMLFormatter
|
||||
from redmine_reporter.formatters.json import JSONFormatter
|
||||
from redmine_reporter.formatters.markdown import MarkdownFormatter
|
||||
from redmine_reporter.formatters.odt import ODTFormatter
|
||||
from redmine_reporter.formatters.xlsx import XLSXFormatter
|
||||
from redmine_reporter.types import ReportRow
|
||||
|
||||
|
||||
def _make_empty_odt_bytes() -> bytes:
|
||||
"""Создаёт минимальный валидный ODT-документ в памяти."""
|
||||
doc = OpenDocumentText()
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def make_fake_report_rows() -> List[ReportRow]:
|
||||
"""
|
||||
Генерирует фейковый отчёт с полным покрытием логики группировки:
|
||||
- Проект A: версия v1.0 (2 задачи), версия v2.0 (1 задача)
|
||||
- Проект B: версия <N/A> (1 задача)
|
||||
- Проект C: версия v1.0 (1 задача), версия v1.1 (2 задачи)
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"project": "Проект A",
|
||||
"version": "v1.0",
|
||||
"display_project": "Проект A",
|
||||
"display_version": "v1.0",
|
||||
"issue_id": 101,
|
||||
"subject": "Реализовать фичу X",
|
||||
"status_ru": "В работе",
|
||||
"time_text": "4ч 30м",
|
||||
"hours": 4.5,
|
||||
},
|
||||
{
|
||||
"project": "Проект A",
|
||||
"version": "v1.0",
|
||||
"display_project": "",
|
||||
"display_version": "",
|
||||
"issue_id": 102,
|
||||
"subject": "Исправить баг Y",
|
||||
"status_ru": "Решена",
|
||||
"time_text": "2ч",
|
||||
"hours": 2.0,
|
||||
},
|
||||
{
|
||||
"project": "Проект A",
|
||||
"version": "v2.0",
|
||||
"display_project": "",
|
||||
"display_version": "v2.0",
|
||||
"issue_id": 103,
|
||||
"subject": "Документация Z",
|
||||
"status_ru": "Ожидание",
|
||||
"time_text": "1ч",
|
||||
"hours": 1.0,
|
||||
},
|
||||
{
|
||||
"project": "Проект B",
|
||||
"version": "<N/A>",
|
||||
"display_project": "Проект B",
|
||||
"display_version": "<N/A>",
|
||||
"issue_id": 201,
|
||||
"subject": "Обновить README",
|
||||
"status_ru": "Закрыто",
|
||||
"time_text": "0ч",
|
||||
"hours": 0.0,
|
||||
},
|
||||
{
|
||||
"project": "Проект C",
|
||||
"version": "v1.0",
|
||||
"display_project": "Проект C",
|
||||
"display_version": "v1.0",
|
||||
"issue_id": 301,
|
||||
"subject": "Настроить CI",
|
||||
"status_ru": "В работе",
|
||||
"time_text": "3ч 15м",
|
||||
"hours": 3.25,
|
||||
},
|
||||
{
|
||||
"project": "Проект C",
|
||||
"version": "v1.1",
|
||||
"display_project": "",
|
||||
"display_version": "v1.1",
|
||||
"issue_id": 302,
|
||||
"subject": "Добавить тесты",
|
||||
"status_ru": "В работе",
|
||||
"time_text": "5ч",
|
||||
"hours": 5.0,
|
||||
},
|
||||
{
|
||||
"project": "Проект C",
|
||||
"version": "v1.1",
|
||||
"display_project": "",
|
||||
"display_version": "",
|
||||
"issue_id": 303,
|
||||
"subject": "Рефакторинг",
|
||||
"status_ru": "Решена",
|
||||
"time_text": "6ч 45м",
|
||||
"hours": 6.75,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_rows():
|
||||
return make_fake_report_rows()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def odt_formatter():
|
||||
"""ODTFormatter с замоканной загрузкой шаблона."""
|
||||
odt_bytes = _make_empty_odt_bytes()
|
||||
mock_file = mock.MagicMock()
|
||||
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
|
||||
mock_file.__exit__ = mock.MagicMock(return_value=False)
|
||||
|
||||
with mock.patch(
|
||||
"redmine_reporter.formatters.odt.resources.files",
|
||||
return_value=mock.MagicMock(
|
||||
joinpath=mock.MagicMock(
|
||||
return_value=mock.MagicMock(open=mock.MagicMock(return_value=mock_file))
|
||||
)
|
||||
),
|
||||
):
|
||||
yield ODTFormatter(
|
||||
author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31"
|
||||
)
|
||||
|
||||
|
||||
# -- Тесты упаковки formatters как полноценного пакета --
|
||||
|
||||
|
||||
def test_formatters_is_regular_package():
|
||||
"""redmine_reporter.formatters — полноценный пакет с __init__.py, не namespace."""
|
||||
import redmine_reporter.formatters
|
||||
|
||||
assert hasattr(redmine_reporter.formatters, "__file__")
|
||||
assert redmine_reporter.formatters.__file__.endswith("__init__.py")
|
||||
|
||||
|
||||
def test_formatters_found_by_setuptools():
|
||||
"""setuptools.find_packages находит formatters как полноценный пакет."""
|
||||
import os
|
||||
|
||||
from setuptools import find_packages
|
||||
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
found = find_packages(where=project_root, include=["redmine_reporter*"])
|
||||
assert "redmine_reporter.formatters" in found
|
||||
|
||||
|
||||
# -- Тесты ленивого импорта ODT (#25) --
|
||||
|
||||
|
||||
def _simulate_missing_odfpy():
|
||||
"""Вспомогательная функция: подготавливает очистку кэша модулей odf и odt.
|
||||
|
||||
Возвращает словарь сохранённых модулей для последующего восстановления.
|
||||
"""
|
||||
import sys
|
||||
|
||||
saved = {}
|
||||
for key in list(sys.modules.keys()):
|
||||
if (
|
||||
key == "odf"
|
||||
or key.startswith("odf.")
|
||||
or key == "redmine_reporter.formatters.odt"
|
||||
):
|
||||
saved[key] = sys.modules.pop(key)
|
||||
return saved
|
||||
|
||||
|
||||
def _restore_modules(saved):
|
||||
import sys
|
||||
|
||||
sys.modules.update(saved)
|
||||
|
||||
|
||||
def test_get_formatter_odt_returns_none_without_odfpy():
|
||||
"""Без odfpy — get_formatter_by_extension('.odt') возвращает None, не падает."""
|
||||
import builtins
|
||||
|
||||
from redmine_reporter.formatters.factory import get_formatter_by_extension
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def blocking_import(name, *args, **kwargs):
|
||||
if name == "odf" or name.startswith("odf."):
|
||||
raise ImportError(f"No module named '{name}'")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
saved = _simulate_missing_odfpy()
|
||||
try:
|
||||
with mock.patch("builtins.__import__", side_effect=blocking_import):
|
||||
result = get_formatter_by_extension(
|
||||
".odt", author="test", from_date="2026-01-01", to_date="2026-01-31"
|
||||
)
|
||||
assert result is None
|
||||
finally:
|
||||
_restore_modules(saved)
|
||||
|
||||
|
||||
def test_get_formatter_csv_works_without_odfpy():
|
||||
"""Без odfpy — get_formatter_by_extension('.csv') работает нормально."""
|
||||
import builtins
|
||||
|
||||
from redmine_reporter.formatters.factory import get_formatter_by_extension
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def blocking_import(name, *args, **kwargs):
|
||||
if name == "odf" or name.startswith("odf."):
|
||||
raise ImportError(f"No module named '{name}'")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
saved = _simulate_missing_odfpy()
|
||||
try:
|
||||
with mock.patch("builtins.__import__", side_effect=blocking_import):
|
||||
result = get_formatter_by_extension(".csv")
|
||||
assert result is not None
|
||||
finally:
|
||||
_restore_modules(saved)
|
||||
|
||||
|
||||
# -- Параметризованные тесты текстовых форматтеров --
|
||||
|
||||
TEXT_FORMATTER_FACTORIES = [
|
||||
("table", lambda: TableFormatter()),
|
||||
("compact", lambda: CompactFormatter()),
|
||||
("csv", lambda: CSVFormatter()),
|
||||
("json", lambda: JSONFormatter()),
|
||||
("markdown", lambda: MarkdownFormatter()),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES)
|
||||
def test_text_formatter_returns_nonempty_string(fake_rows, name, factory):
|
||||
"""Текстовые форматтеры возвращают непустую строку."""
|
||||
result = factory().format(fake_rows)
|
||||
assert isinstance(result, str)
|
||||
assert len(result.strip()) > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES)
|
||||
def test_text_formatter_contains_key_content(fake_rows, name, factory):
|
||||
"""Вывод содержит ключевые данные из отчёта."""
|
||||
output = factory().format(fake_rows)
|
||||
|
||||
assert "Проект A" in output
|
||||
assert "Проект B" in output
|
||||
assert "В работе" in output
|
||||
assert "<N/A>" in output
|
||||
assert "6ч 45м" in output
|
||||
|
||||
if name == "csv":
|
||||
# В CSV issue_id и subject -- отдельные колонки
|
||||
assert "101" in output
|
||||
assert "Реализовать фичу X" in output
|
||||
elif name == "json":
|
||||
# В JSON issue_id -- число, subject -- отдельное поле
|
||||
assert '"issue_id": 101' in output
|
||||
assert "Реализовать фичу X" in output
|
||||
else:
|
||||
assert "101. Реализовать фичу X" in output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES)
|
||||
def test_text_formatter_empty_rows(name, factory):
|
||||
"""Форматтер не падает на пустом списке строк."""
|
||||
result = factory().format([])
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# -- Тесты консольных форматтеров --
|
||||
|
||||
|
||||
def test_table_formatter_save_raises(fake_rows):
|
||||
with pytest.raises(NotImplementedError):
|
||||
TableFormatter().save(fake_rows, "/dev/null")
|
||||
|
||||
|
||||
def test_compact_formatter_save_raises(fake_rows):
|
||||
with pytest.raises(NotImplementedError):
|
||||
CompactFormatter().save(fake_rows, "/dev/null")
|
||||
|
||||
|
||||
def test_csv_save_writes_utf8_bom(fake_rows, tmp_path):
|
||||
"""CSV-файл начинается с UTF-8 BOM для корректного открытия в Excel (#26)."""
|
||||
output = tmp_path / "report.csv"
|
||||
CSVFormatter().save(fake_rows, str(output))
|
||||
content = output.read_bytes()
|
||||
assert content[:3] == b"\xef\xbb\xbf" # UTF-8 BOM
|
||||
|
||||
|
||||
def test_csv_uses_full_values_not_display(fake_rows):
|
||||
"""CSV экспортирует полные project/version, а не display-значения (#31).
|
||||
|
||||
В отличие от консольных и Markdown форматтеров (display_*), CSV содержит
|
||||
полные значения в каждой строке — это корректно для табличного формата.
|
||||
"""
|
||||
output = CSVFormatter().format(fake_rows)
|
||||
lines = output.strip().split("\n")
|
||||
# Header + 7 data rows = 8 lines
|
||||
assert len(lines) == 8
|
||||
# Вторая строка данных (lines[2]) имеет display_project="" и display_version="",
|
||||
# но CSV должен содержать полные значения
|
||||
assert "Проект A" in lines[2]
|
||||
assert "v1.0" in lines[2]
|
||||
|
||||
|
||||
def test_json_save_writes_parsable_data(fake_rows, tmp_path):
|
||||
"""JSON-файл содержит валидный JSON со всеми строками отчёта."""
|
||||
output = tmp_path / "report.json"
|
||||
JSONFormatter().save(fake_rows, str(output))
|
||||
data = json.loads(output.read_text(encoding="utf-8"))
|
||||
assert len(data) == len(fake_rows)
|
||||
assert data[0]["project"] == "Проект A"
|
||||
assert data[0]["issue_id"] == 101
|
||||
|
||||
|
||||
def test_get_formatter_by_extension_json():
|
||||
"""get_formatter_by_extension('.json') возвращает JSONFormatter."""
|
||||
formatter = get_formatter_by_extension(".json")
|
||||
assert isinstance(formatter, JSONFormatter)
|
||||
|
||||
|
||||
def test_get_formatter_by_extension_xlsx():
|
||||
"""get_formatter_by_extension('.xlsx') возвращает XLSXFormatter."""
|
||||
formatter = get_formatter_by_extension(".xlsx")
|
||||
assert isinstance(formatter, XLSXFormatter)
|
||||
|
||||
|
||||
def test_xlsx_save_creates_valid_file(fake_rows, tmp_path):
|
||||
"""XLSX-файл сохраняется и содержит корректные данные."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
output = tmp_path / "report.xlsx"
|
||||
XLSXFormatter().save(fake_rows, str(output))
|
||||
|
||||
wb = load_workbook(str(output))
|
||||
ws = wb.active
|
||||
assert ws.title == "Report"
|
||||
assert ws["A1"].value == "Project"
|
||||
assert ws["A2"].value == "Проект A"
|
||||
assert ws["C2"].value == 101
|
||||
assert ws["F2"].value == 4.5
|
||||
assert ws["G2"].value == "4ч 30м"
|
||||
# header + 7 data rows + 5 version totals + 3 project totals + 1 grand total
|
||||
assert ws.max_row == 17
|
||||
|
||||
|
||||
def test_xlsx_has_merged_cells(fake_rows, tmp_path):
|
||||
"""XLSX содержит объединённые ячейки по проектам и версиям."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
output = tmp_path / "report.xlsx"
|
||||
XLSXFormatter().save(fake_rows, str(output))
|
||||
|
||||
wb = load_workbook(str(output))
|
||||
ws = wb.active
|
||||
merged_ranges = [str(r) for r in ws.merged_cells.ranges]
|
||||
# Проект A: 3 строки данных + 2 итога по версиям + 1 итог по проекту = строки 2-7
|
||||
assert any("A2:A7" in r for r in merged_ranges)
|
||||
# Версия v1.0 проекта A: 2 строки данных + 1 итог по версии = строки 2-4
|
||||
assert any("B2:B4" in r for r in merged_ranges)
|
||||
|
||||
|
||||
def test_xlsx_has_totals(fake_rows, tmp_path):
|
||||
"""XLSX содержит итоги по версиям, проектам и общий итог."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
output = tmp_path / "report.xlsx"
|
||||
XLSXFormatter().save(fake_rows, str(output))
|
||||
|
||||
wb = load_workbook(str(output))
|
||||
ws = wb.active
|
||||
|
||||
total_values = [ws.cell(row=r, column=6).value for r in range(2, ws.max_row + 1)]
|
||||
# Проект A всего: 4.5 + 2 + 1 = 7.5
|
||||
assert 7.5 in total_values
|
||||
# Общий итог: 4.5 + 2 + 1 + 0 + 3.25 + 5 + 6.75 = 22.5
|
||||
assert 22.5 in total_values
|
||||
# Проверим числовой формат
|
||||
assert ws["F2"].number_format == "0.00"
|
||||
|
||||
|
||||
def test_xlsx_has_full_header_row_and_grand_total_row(fake_rows, tmp_path):
|
||||
"""XLSX содержит полный ряд заголовков колонок и финальную строку общего итога."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
output = tmp_path / "report.xlsx"
|
||||
XLSXFormatter().save(fake_rows, str(output))
|
||||
|
||||
wb = load_workbook(str(output))
|
||||
ws = wb.active
|
||||
|
||||
headers = [ws.cell(row=1, column=c).value for c in range(1, 8)]
|
||||
assert headers == [
|
||||
"Project",
|
||||
"Version",
|
||||
"Issue ID",
|
||||
"Subject",
|
||||
"Status",
|
||||
"Hours",
|
||||
"Spent Time",
|
||||
]
|
||||
|
||||
# Последняя строка — общий итог по всем проектам
|
||||
assert ws.cell(row=ws.max_row, column=1).value == "Total"
|
||||
assert ws.cell(row=ws.max_row, column=6).value == 22.5
|
||||
|
||||
|
||||
def test_xlsx_no_time_keeps_columns_empty_and_skips_totals(fake_rows, tmp_path):
|
||||
"""XLSX с no_time: колонки времени пустые, итогов нет."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
output = tmp_path / "report.xlsx"
|
||||
XLSXFormatter(no_time=True).save(fake_rows, str(output))
|
||||
|
||||
wb = load_workbook(str(output))
|
||||
ws = wb.active
|
||||
|
||||
# header + 7 data rows
|
||||
assert ws.max_row == 8
|
||||
|
||||
# Колонки времени пустые для всех строк данных
|
||||
for row in range(2, ws.max_row + 1):
|
||||
assert ws.cell(row=row, column=6).value in (None, "")
|
||||
assert ws.cell(row=row, column=7).value in (None, "")
|
||||
|
||||
# Итоговых строк нет
|
||||
for row in range(2, ws.max_row + 1):
|
||||
assert not str(ws.cell(row=row, column=1).value or "").startswith("Total")
|
||||
assert not str(ws.cell(row=row, column=2).value or "").startswith("Total")
|
||||
|
||||
# Автофильтр и freeze panes на месте
|
||||
assert ws.freeze_panes == "A2"
|
||||
assert ws.auto_filter.ref == "A1:G8"
|
||||
|
||||
|
||||
def test_markdown_formatter_escapes_table_cells():
|
||||
rows = make_fake_report_rows()
|
||||
rows[0]["project"] = "A|B"
|
||||
rows[0]["display_project"] = "A|B"
|
||||
rows[0]["subject"] = "Fix | split\nline"
|
||||
|
||||
output = MarkdownFormatter().format(rows)
|
||||
|
||||
assert "A\\|B" in output
|
||||
assert "101. Fix \\| split<br>line" in output
|
||||
|
||||
|
||||
def test_html_formatter_escapes_cells():
|
||||
rows = make_fake_report_rows()
|
||||
rows[0]["project"] = 'A&B "<Project>"'
|
||||
rows[0]["display_project"] = rows[0]["project"]
|
||||
rows[0]["subject"] = "Fix <tag> & attrs"
|
||||
|
||||
output = HTMLFormatter().format(rows)
|
||||
|
||||
assert "A&B "<Project>"" in output
|
||||
assert "101. Fix <tag> & attrs" in output
|
||||
assert "Fix <tag>" not in output
|
||||
|
||||
|
||||
def test_html_output_has_doctype_and_charset(fake_rows):
|
||||
"""HTML-отчёт содержит DOCTYPE и meta charset для корректной кодировки (#27)."""
|
||||
output = HTMLFormatter().format(fake_rows)
|
||||
assert "<!DOCTYPE html>" in output
|
||||
assert '<meta charset="utf-8">' in output
|
||||
|
||||
|
||||
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 форматтера --
|
||||
|
||||
|
||||
def test_odt_formatter_returns_opendocument(fake_rows, odt_formatter):
|
||||
"""ODTFormatter.format() возвращает объект OpenDocument."""
|
||||
result = odt_formatter.format(fake_rows)
|
||||
assert isinstance(result, OpenDocument)
|
||||
|
||||
|
||||
def test_odt_empty_author_no_garbage_in_header(fake_rows):
|
||||
"""При пустом авторе заголовок не содержит мусорных символов (#30)."""
|
||||
odt_bytes = _make_empty_odt_bytes()
|
||||
mock_file = mock.MagicMock()
|
||||
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
|
||||
mock_file.__exit__ = mock.MagicMock(return_value=False)
|
||||
|
||||
with mock.patch(
|
||||
"redmine_reporter.formatters.odt.resources.files",
|
||||
return_value=mock.MagicMock(
|
||||
joinpath=mock.MagicMock(
|
||||
return_value=mock.MagicMock(open=mock.MagicMock(return_value=mock_file))
|
||||
)
|
||||
),
|
||||
):
|
||||
formatter = ODTFormatter(
|
||||
author="", from_date="2026-01-01", to_date="2026-01-31"
|
||||
)
|
||||
doc = formatter.format(fake_rows)
|
||||
|
||||
from odf.text import P
|
||||
|
||||
paragraphs = doc.text.getElementsByType(P)
|
||||
header_text = paragraphs[0].firstChild.data
|
||||
|
||||
assert not header_text.startswith(".")
|
||||
assert "Отчет за месяц" in header_text
|
||||
|
||||
|
||||
def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
|
||||
"""ODT можно сохранить -- файл валиден (сигнатура ZIP)."""
|
||||
odt_bytes = _make_empty_odt_bytes()
|
||||
mock_file = mock.MagicMock()
|
||||
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
|
||||
mock_file.__exit__ = mock.MagicMock(return_value=False)
|
||||
|
||||
with mock.patch(
|
||||
"redmine_reporter.formatters.odt.resources.files",
|
||||
return_value=mock.MagicMock(
|
||||
joinpath=mock.MagicMock(
|
||||
return_value=mock.MagicMock(open=mock.MagicMock(return_value=mock_file))
|
||||
)
|
||||
),
|
||||
):
|
||||
formatter = ODTFormatter(
|
||||
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
|
||||
)
|
||||
output_file = tmp_path / "report.odt"
|
||||
formatter.save(fake_rows, str(output_file))
|
||||
|
||||
assert output_file.exists()
|
||||
assert output_file.read_bytes()[:2] == b"PK" # сигнатура ZIP
|
||||
|
||||
|
||||
def test_odt_has_covered_cells_for_spans(fake_rows):
|
||||
"""ODT содержит covered-table-cell для замещённых ячеек при объединении (#13).
|
||||
|
||||
Тестовые данные (fake_rows):
|
||||
Проект A: v1.0(2 задачи), v2.0(1) → project span=3
|
||||
row1: project+version (0 covered)
|
||||
row2: covered project + covered version (2)
|
||||
row3: covered project + new version cell (1)
|
||||
Проект B: <N/A>(1) → span=1, нет covered
|
||||
Проект C: v1.0(1), v1.1(2) → project span=3
|
||||
row5: project+version (0 covered)
|
||||
row6: covered project + new version cell (1)
|
||||
row7: covered project + covered version (2)
|
||||
Итого: 6 covered cells
|
||||
"""
|
||||
odt_bytes = _make_empty_odt_bytes()
|
||||
mock_file = mock.MagicMock()
|
||||
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
|
||||
mock_file.__exit__ = mock.MagicMock(return_value=False)
|
||||
|
||||
with mock.patch(
|
||||
"redmine_reporter.formatters.odt.resources.files",
|
||||
return_value=mock.MagicMock(
|
||||
joinpath=mock.MagicMock(
|
||||
return_value=mock.MagicMock(open=mock.MagicMock(return_value=mock_file))
|
||||
)
|
||||
),
|
||||
):
|
||||
formatter = ODTFormatter(
|
||||
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
|
||||
)
|
||||
doc = formatter.format(fake_rows)
|
||||
|
||||
from odf.table import CoveredTableCell
|
||||
|
||||
covered_cells = doc.getElementsByType(CoveredTableCell)
|
||||
assert len(covered_cells) == 6
|
||||
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"
|
||||
333
tests/test_mailer.py
Normal file
333
tests/test_mailer.py
Normal file
@@ -0,0 +1,333 @@
|
||||
import smtplib
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from redmine_reporter.client import RedmineAPIError
|
||||
from redmine_reporter.config import EmailConfig, SmtpConfig
|
||||
from redmine_reporter.mailer import build_message, send_report
|
||||
|
||||
|
||||
def _make_email_config(**overrides) -> EmailConfig:
|
||||
"""Создаёт EmailConfig с минимальными валидными настройками."""
|
||||
smtp = SmtpConfig(
|
||||
host=overrides.pop("smtp_host", "smtp.example.com"),
|
||||
port=overrides.pop("smtp_port", 587),
|
||||
user=overrides.pop("smtp_user", "bot@example.com"),
|
||||
password=overrides.pop("smtp_password", "secret"),
|
||||
tls=overrides.pop("smtp_tls", True),
|
||||
)
|
||||
return EmailConfig(
|
||||
smtp=smtp,
|
||||
from_=overrides.pop("from_", "bot@example.com"),
|
||||
to=overrides.pop("to", ["boss@example.com"]),
|
||||
cc=overrides.pop("cc", []),
|
||||
bcc=overrides.pop("bcc", []),
|
||||
subject=overrides.pop("subject", "Отчёт {author} за {period}"),
|
||||
body_text=overrides.pop("body_text", "Во вложении отчёт."),
|
||||
attach=overrides.pop("attach", True),
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildMessage:
|
||||
"""Тесты формирования MIME-письма."""
|
||||
|
||||
def test_subject_substitution(self):
|
||||
"""{author} и {period} подставляются в тему."""
|
||||
cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False)
|
||||
msg = build_message(
|
||||
cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30", []
|
||||
)
|
||||
assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30"
|
||||
|
||||
def test_body_substitution(self):
|
||||
"""{author} и {period} подставляются в тело."""
|
||||
cfg = _make_email_config(
|
||||
body_text="Автор: {author}, период: {period}", attach=False
|
||||
)
|
||||
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", [])
|
||||
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||
assert len(plain_parts) == 1
|
||||
payload = plain_parts[0].get_payload(decode=True).decode("utf-8")
|
||||
assert "Автор: Иванов, период: Q1" in payload
|
||||
|
||||
def test_wire_format_is_8bit_utf8(self):
|
||||
"""Сериализация в байты (как в smtplib.send_message) — 8bit UTF-8."""
|
||||
from email.generator import BytesGenerator
|
||||
from io import BytesIO
|
||||
|
||||
cfg = _make_email_config(body_text="Автор: {author}", attach=False)
|
||||
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
|
||||
|
||||
buf = BytesIO()
|
||||
BytesGenerator(buf, policy=msg.policy.clone(linesep="\r\n")).flatten(msg)
|
||||
data = buf.getvalue()
|
||||
|
||||
assert b"Content-Transfer-Encoding: 8bit" in data
|
||||
assert "Автор: Иванов".encode("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):
|
||||
cfg = _make_email_config(from_="sender@example.com", attach=False)
|
||||
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||
assert msg["From"] == "sender@example.com"
|
||||
|
||||
def test_to_header(self):
|
||||
cfg = _make_email_config(to=["a@x.com", "b@x.com"], attach=False)
|
||||
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||
assert msg["To"] == "a@x.com, b@x.com"
|
||||
|
||||
def test_cc_header(self):
|
||||
cfg = _make_email_config(cc=["cc@x.com"], attach=False)
|
||||
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||
assert msg["Cc"] == "cc@x.com"
|
||||
|
||||
def test_bcc_not_in_headers(self):
|
||||
"""BCC не должен появляться в заголовках письма."""
|
||||
cfg = _make_email_config(bcc=["hidden@x.com"], attach=False)
|
||||
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
|
||||
assert "Bcc" not in msg
|
||||
|
||||
def test_attachment_present_when_attach_true(self, tmp_path):
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake xlsx content")
|
||||
cfg = _make_email_config(attach=True)
|
||||
msg = build_message(cfg, str(report), "A", "P", [])
|
||||
assert len(msg.get_payload()) == 2
|
||||
|
||||
def test_no_attachment_when_attach_false(self, tmp_path):
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake xlsx content")
|
||||
cfg = _make_email_config(attach=False)
|
||||
msg = build_message(cfg, str(report), "A", "P", [])
|
||||
assert len(msg.get_payload()) == 1
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ext,expected_mime",
|
||||
[
|
||||
(
|
||||
".xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
),
|
||||
(".odt", "application/vnd.oasis.opendocument.text"),
|
||||
(".csv", "text/csv"),
|
||||
(".html", "text/html"),
|
||||
(".json", "application/json"),
|
||||
(".md", "text/markdown"),
|
||||
],
|
||||
)
|
||||
def test_mime_type_by_extension(self, ext, expected_mime, tmp_path):
|
||||
report = tmp_path / f"report{ext}"
|
||||
report.write_text("content")
|
||||
cfg = _make_email_config()
|
||||
msg = build_message(cfg, str(report), "A", "P", [])
|
||||
attachment_part = msg.get_payload()[1]
|
||||
assert attachment_part.get_content_type() == expected_mime
|
||||
|
||||
def test_missing_attachment_file_raises_error(self, tmp_path):
|
||||
"""Если файл вложения не существует — RedmineAPIError."""
|
||||
cfg = _make_email_config()
|
||||
missing = str(tmp_path / "nonexistent.xlsx")
|
||||
with pytest.raises(RedmineAPIError, match="Не удалось прочитать файл"):
|
||||
build_message(cfg, missing, "A", "P", [])
|
||||
|
||||
def test_html_part_present_when_html_true(self, tmp_path):
|
||||
"""При email.html: true письмо содержит multipart/alternative с HTML."""
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config(html=True)
|
||||
rows = [
|
||||
{
|
||||
"project": "Project",
|
||||
"version": "1.0",
|
||||
"issue_id": 1,
|
||||
"subject": "Task",
|
||||
"status_ru": "В работе",
|
||||
"time_text": "2ч",
|
||||
"hours": 2.0,
|
||||
}
|
||||
]
|
||||
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||
|
||||
alternatives = [
|
||||
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
|
||||
]
|
||||
assert len(alternatives) == 1
|
||||
|
||||
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
|
||||
assert len(html_parts) == 1
|
||||
|
||||
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||
assert len(plain_parts) == 1
|
||||
|
||||
def test_no_html_part_when_html_false(self, tmp_path):
|
||||
"""При email.html: false письмо содержит только plain-text."""
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config(html=False)
|
||||
msg = build_message(cfg, str(report), "A", "P", [])
|
||||
|
||||
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
|
||||
assert len(html_parts) == 0
|
||||
|
||||
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
|
||||
assert len(plain_parts) == 1
|
||||
|
||||
def test_html_part_contains_report_data(self, tmp_path):
|
||||
"""HTML-часть содержит данные отчёта."""
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config(html=True)
|
||||
rows = [
|
||||
{
|
||||
"project": "Project X",
|
||||
"version": "2.0",
|
||||
"issue_id": 42,
|
||||
"subject": "Important task",
|
||||
"status_ru": "В работе",
|
||||
"time_text": "3ч 30м",
|
||||
"hours": 3.5,
|
||||
}
|
||||
]
|
||||
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||
|
||||
html_part = [p for p in msg.walk() if p.get_content_type() == "text/html"][0]
|
||||
payload = html_part.get_payload(decode=True).decode("utf-8")
|
||||
assert "Project X" in payload
|
||||
assert "Important task" in payload
|
||||
|
||||
def test_plain_text_before_html_in_alternative(self, tmp_path):
|
||||
"""В multipart/alternative plain-text идёт перед HTML."""
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config(html=True)
|
||||
rows = [
|
||||
{
|
||||
"project": "P",
|
||||
"version": "V",
|
||||
"issue_id": 1,
|
||||
"subject": "S",
|
||||
"status_ru": "В работе",
|
||||
"time_text": "1ч",
|
||||
"hours": 1.0,
|
||||
}
|
||||
]
|
||||
msg = build_message(cfg, str(report), "A", "P", rows)
|
||||
|
||||
alternative = [
|
||||
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
|
||||
][0]
|
||||
payloads = alternative.get_payload()
|
||||
assert payloads[0].get_content_type() == "text/plain"
|
||||
assert payloads[1].get_content_type() == "text/html"
|
||||
|
||||
|
||||
class TestSendReport:
|
||||
"""Тесты отправки письма."""
|
||||
|
||||
def test_send_success(self, tmp_path):
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config()
|
||||
|
||||
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||
mock_smtp = mock.MagicMock()
|
||||
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||
|
||||
send_report(cfg, str(report), "Кокос А.Н.", "2026-06-01--2026-06-30", [])
|
||||
|
||||
mock_smtp_class.assert_called_once_with("smtp.example.com", 587, timeout=30)
|
||||
mock_smtp.starttls.assert_called_once()
|
||||
mock_smtp.login.assert_called_once_with("bot@example.com", "secret")
|
||||
mock_smtp.send_message.assert_called_once()
|
||||
|
||||
def test_send_no_tls(self, tmp_path):
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config(smtp_tls=False)
|
||||
|
||||
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||
mock_smtp = mock.MagicMock()
|
||||
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||
|
||||
send_report(cfg, str(report), "A", "P", [])
|
||||
|
||||
mock_smtp.starttls.assert_not_called()
|
||||
|
||||
def test_send_connection_error(self, tmp_path):
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config()
|
||||
|
||||
with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")):
|
||||
with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"):
|
||||
send_report(cfg, str(report), "A", "P", [])
|
||||
|
||||
def test_send_auth_error(self, tmp_path):
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config()
|
||||
|
||||
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||
mock_smtp = mock.MagicMock()
|
||||
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||
mock_smtp.login.side_effect = smtplib.SMTPAuthenticationError(
|
||||
535, b"Bad auth"
|
||||
)
|
||||
|
||||
with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"):
|
||||
send_report(cfg, str(report), "A", "P", [])
|
||||
|
||||
def test_send_timeout_error(self, tmp_path):
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config()
|
||||
|
||||
with mock.patch("smtplib.SMTP", side_effect=TimeoutError()):
|
||||
with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"):
|
||||
send_report(cfg, str(report), "A", "P", [])
|
||||
|
||||
def test_send_generic_smtp_error(self, tmp_path):
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config()
|
||||
|
||||
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||
mock_smtp = mock.MagicMock()
|
||||
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||
mock_smtp.send_message.side_effect = smtplib.SMTPException(
|
||||
"Something went wrong"
|
||||
)
|
||||
|
||||
with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"):
|
||||
send_report(cfg, str(report), "A", "P", [])
|
||||
|
||||
def test_send_includes_cc_and_bcc(self, tmp_path):
|
||||
report = tmp_path / "report.xlsx"
|
||||
report.write_text("fake content")
|
||||
cfg = _make_email_config(to=["a@x.com"], cc=["cc@x.com"], bcc=["bcc@x.com"])
|
||||
|
||||
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||
mock_smtp = mock.MagicMock()
|
||||
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||
|
||||
send_report(cfg, str(report), "A", "P", [])
|
||||
|
||||
call_args = mock_smtp.send_message.call_args
|
||||
msg = call_args.args[0]
|
||||
assert msg["To"] == "a@x.com"
|
||||
assert msg["Cc"] == "cc@x.com"
|
||||
303
tests/test_report_builder.py
Normal file
303
tests/test_report_builder.py
Normal file
@@ -0,0 +1,303 @@
|
||||
from redmine_reporter.report_builder import (
|
||||
STATUS_TRANSLATION,
|
||||
build_grouped_report,
|
||||
calculate_summary,
|
||||
group_rows_by_project_and_version,
|
||||
)
|
||||
|
||||
|
||||
class MockIssue:
|
||||
def __init__(self, project, subject, status, fixed_version=None, issue_id=999):
|
||||
self.id = issue_id
|
||||
self.project = project
|
||||
self.subject = subject
|
||||
self.status = status
|
||||
if fixed_version is not None:
|
||||
self.fixed_version = fixed_version
|
||||
|
||||
|
||||
# -- Таблица переводов статусов --
|
||||
|
||||
|
||||
def test_status_translation():
|
||||
assert STATUS_TRANSLATION["Closed"] == "Закрыто"
|
||||
assert STATUS_TRANSLATION["New"] == "В работе"
|
||||
assert STATUS_TRANSLATION["Resolved"] == "Решена"
|
||||
assert STATUS_TRANSLATION["Pending"] == "Ожидание"
|
||||
|
||||
|
||||
def test_status_translation_unknown_passthrough():
|
||||
"""Неизвестный статус возвращается как есть."""
|
||||
from redmine_reporter.report_builder import STATUS_TRANSLATION
|
||||
|
||||
assert "SomeNewStatus" not in STATUS_TRANSLATION
|
||||
# build_grouped_report вернёт оригинальное значение
|
||||
issue = MockIssue("P", "S", "SomeNewStatus", "v1.0", 1)
|
||||
rows = build_grouped_report([(issue, 1.0)])
|
||||
assert rows[0]["status_ru"] == "SomeNewStatus"
|
||||
|
||||
|
||||
# -- Основная логика группировки --
|
||||
|
||||
|
||||
def test_build_grouped_report_grouping():
|
||||
issues = [
|
||||
(MockIssue("Камеры", "Фича A", "New", "v2.5.0", 101), 2.0),
|
||||
(MockIssue("Камеры", "Баг B", "Resolved", "v2.5.0", 102), 1.5),
|
||||
(MockIssue("ПО", "Доки", "Pending", None, 201), 4.0),
|
||||
]
|
||||
rows = build_grouped_report(issues)
|
||||
|
||||
assert len(rows) == 3
|
||||
# Первая строка -- полное название проекта и версии
|
||||
assert rows[0]["display_project"] == "Камеры"
|
||||
assert rows[0]["display_version"] == "v2.5.0"
|
||||
# Вторая -- пустые display_* из-за совпадения проекта+версии
|
||||
assert rows[1]["display_project"] == ""
|
||||
assert rows[1]["display_version"] == ""
|
||||
# Третья -- новый проект
|
||||
assert rows[2]["display_project"] == "ПО"
|
||||
assert rows[2]["display_version"] == "<N/A>"
|
||||
|
||||
assert rows[0]["status_ru"] == "В работе"
|
||||
assert rows[0]["time_text"] == "2ч"
|
||||
assert rows[0]["hours"] == 2.0
|
||||
assert rows[1]["time_text"] == "1ч 30м"
|
||||
assert rows[1]["hours"] == 1.5
|
||||
|
||||
|
||||
# -- #22: Сводка по времени --
|
||||
|
||||
|
||||
def test_calculate_summary_totals():
|
||||
"""Сводка содержит общее время и разбивку по проектам/версиям."""
|
||||
rows = [
|
||||
{
|
||||
"project": "Камеры",
|
||||
"version": "v2.5.0",
|
||||
"issue_id": 101,
|
||||
"subject": "Фича A",
|
||||
"status_ru": "В работе",
|
||||
"time_text": "2ч",
|
||||
"hours": 2.0,
|
||||
},
|
||||
{
|
||||
"project": "Камеры",
|
||||
"version": "v2.5.0",
|
||||
"issue_id": 102,
|
||||
"subject": "Баг B",
|
||||
"status_ru": "Решена",
|
||||
"time_text": "1ч 30м",
|
||||
"hours": 1.5,
|
||||
},
|
||||
{
|
||||
"project": "ПО",
|
||||
"version": "<N/A>",
|
||||
"issue_id": 201,
|
||||
"subject": "Доки",
|
||||
"status_ru": "Ожидание",
|
||||
"time_text": "4ч",
|
||||
"hours": 4.0,
|
||||
},
|
||||
]
|
||||
summary = calculate_summary(rows)
|
||||
|
||||
assert summary["total"] == 7.5
|
||||
assert summary["project:Камеры"] == 3.5
|
||||
assert summary["project:ПО"] == 4.0
|
||||
assert summary["version:Камеры::v2.5.0"] == 3.5
|
||||
assert summary["version:ПО::<N/A>"] == 4.0
|
||||
|
||||
|
||||
def test_calculate_summary_empty():
|
||||
"""Сводка для пустого списка -- только total = 0."""
|
||||
summary = calculate_summary([])
|
||||
assert summary == {"total": 0.0}
|
||||
|
||||
|
||||
# -- #19: Общая функция группировки --
|
||||
|
||||
|
||||
def test_build_grouped_report_new_version_same_project():
|
||||
"""Смена версии внутри одного проекта -- display_project пустой, display_version новая."""
|
||||
issues = [
|
||||
(MockIssue("Камеры", "Задача 1", "New", "v1.0", 1), 1.0),
|
||||
(MockIssue("Камеры", "Задача 2", "New", "v2.0", 2), 1.0),
|
||||
]
|
||||
rows = build_grouped_report(issues)
|
||||
assert rows[0]["display_project"] == "Камеры"
|
||||
assert rows[0]["display_version"] == "v1.0"
|
||||
assert rows[1]["display_project"] == ""
|
||||
assert rows[1]["display_version"] == "v2.0"
|
||||
|
||||
|
||||
def test_build_grouped_report_sorts_input():
|
||||
"""Несортированный вход -- результат всё равно корректно сгруппирован."""
|
||||
issues = [
|
||||
(MockIssue("ПО", "Задача B", "New", "v1.0", 2), 1.0),
|
||||
(MockIssue("Камеры", "Задача A", "New", "v1.0", 1), 2.0),
|
||||
]
|
||||
rows = build_grouped_report(issues)
|
||||
# После сортировки "Камеры" < "ПО" (лексикографически по кириллице)
|
||||
assert rows[0]["project"] == "Камеры"
|
||||
assert rows[1]["project"] == "ПО"
|
||||
# Оба display_project непустые -- разные проекты
|
||||
assert rows[0]["display_project"] == "Камеры"
|
||||
assert rows[1]["display_project"] == "ПО"
|
||||
|
||||
|
||||
def test_build_grouped_report_empty():
|
||||
"""Пустой вход -- пустой результат, без исключений."""
|
||||
rows = build_grouped_report([])
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_build_grouped_report_no_time():
|
||||
"""fill_time=False -- time_text пустой для всех строк."""
|
||||
issues = [(MockIssue("P", "S", "New", "v1.0", 1), 3.5)]
|
||||
rows = build_grouped_report(issues, fill_time=False)
|
||||
assert rows[0]["time_text"] == ""
|
||||
|
||||
|
||||
def test_build_grouped_report_preserves_issue_id_and_subject():
|
||||
issues = [(MockIssue("P", "Моя задача", "Closed", "v1.0", 42), 0.5)]
|
||||
rows = build_grouped_report(issues)
|
||||
assert rows[0]["issue_id"] == 42
|
||||
assert rows[0]["subject"] == "Моя задача"
|
||||
|
||||
|
||||
def test_build_grouped_report_deterministic_order_within_group():
|
||||
"""Задачи в одной группе проект+версия упорядочены по issue.id
|
||||
независимо от порядка на входе (#33)."""
|
||||
issues = [
|
||||
(MockIssue("P", "Task C", "New", "v1.0", 103), 1.0),
|
||||
(MockIssue("P", "Task A", "New", "v1.0", 101), 1.0),
|
||||
(MockIssue("P", "Task B", "New", "v1.0", 102), 1.0),
|
||||
]
|
||||
rows = build_grouped_report(issues)
|
||||
assert [r["issue_id"] for r in rows] == [101, 102, 103]
|
||||
|
||||
|
||||
# -- #19: Общая функция группировки --
|
||||
|
||||
|
||||
def test_group_rows_basic():
|
||||
"""Два проекта, в одном две версии — структура корректна."""
|
||||
rows = [
|
||||
{
|
||||
"project": "A",
|
||||
"version": "v1",
|
||||
"issue_id": 1,
|
||||
"subject": "T1",
|
||||
"status_ru": "S",
|
||||
"time_text": "1ч",
|
||||
},
|
||||
{
|
||||
"project": "A",
|
||||
"version": "v2",
|
||||
"issue_id": 2,
|
||||
"subject": "T2",
|
||||
"status_ru": "S",
|
||||
"time_text": "2ч",
|
||||
},
|
||||
{
|
||||
"project": "B",
|
||||
"version": "v1",
|
||||
"issue_id": 3,
|
||||
"subject": "T3",
|
||||
"status_ru": "S",
|
||||
"time_text": "3ч",
|
||||
},
|
||||
]
|
||||
grouped = group_rows_by_project_and_version(rows)
|
||||
|
||||
assert list(grouped.keys()) == ["A", "B"]
|
||||
assert list(grouped["A"].keys()) == ["v1", "v2"]
|
||||
assert list(grouped["B"].keys()) == ["v1"]
|
||||
assert len(grouped["A"]["v1"]) == 1
|
||||
assert len(grouped["A"]["v2"]) == 1
|
||||
assert len(grouped["B"]["v1"]) == 1
|
||||
|
||||
|
||||
def test_group_rows_multiple_tasks_per_version():
|
||||
"""Несколько задач в одной версии — все попадают в список."""
|
||||
rows = [
|
||||
{
|
||||
"project": "A",
|
||||
"version": "v1",
|
||||
"issue_id": 1,
|
||||
"subject": "T1",
|
||||
"status_ru": "S",
|
||||
"time_text": "1ч",
|
||||
},
|
||||
{
|
||||
"project": "A",
|
||||
"version": "v1",
|
||||
"issue_id": 2,
|
||||
"subject": "T2",
|
||||
"status_ru": "S",
|
||||
"time_text": "2ч",
|
||||
},
|
||||
{
|
||||
"project": "A",
|
||||
"version": "v1",
|
||||
"issue_id": 3,
|
||||
"subject": "T3",
|
||||
"status_ru": "S",
|
||||
"time_text": "3ч",
|
||||
},
|
||||
]
|
||||
grouped = group_rows_by_project_and_version(rows)
|
||||
|
||||
assert len(grouped["A"]["v1"]) == 3
|
||||
assert [r["issue_id"] for r in grouped["A"]["v1"]] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_group_rows_preserves_row_data():
|
||||
"""Строки в группировке — те же объекты, без потери данных."""
|
||||
rows = [
|
||||
{
|
||||
"project": "P",
|
||||
"version": "v1",
|
||||
"issue_id": 42,
|
||||
"subject": "Task",
|
||||
"status_ru": "Готово",
|
||||
"time_text": "5ч",
|
||||
},
|
||||
]
|
||||
grouped = group_rows_by_project_and_version(rows)
|
||||
|
||||
row = grouped["P"]["v1"][0]
|
||||
assert row["issue_id"] == 42
|
||||
assert row["subject"] == "Task"
|
||||
assert row["status_ru"] == "Готово"
|
||||
assert row["time_text"] == "5ч"
|
||||
|
||||
|
||||
def test_group_rows_empty():
|
||||
"""Пустой список — пустой словарь."""
|
||||
assert group_rows_by_project_and_version([]) == {}
|
||||
|
||||
|
||||
# -- #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"] == "Закрыто"
|
||||
112
tests/test_utils.py
Normal file
112
tests/test_utils.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from redmine_reporter.utils import (
|
||||
get_month_name_from_range,
|
||||
get_version,
|
||||
hours_to_human,
|
||||
)
|
||||
|
||||
|
||||
def test_hours_to_human_zero():
|
||||
assert hours_to_human(0) == "0ч"
|
||||
assert hours_to_human(-1) == "0ч"
|
||||
|
||||
|
||||
def test_hours_to_human_whole_hours():
|
||||
assert hours_to_human(1.0) == "1ч"
|
||||
assert hours_to_human(8.0) == "8ч"
|
||||
|
||||
|
||||
def test_hours_to_human_minutes_only():
|
||||
assert hours_to_human(0.75) == "45м"
|
||||
assert hours_to_human(0.5) == "30м"
|
||||
|
||||
|
||||
def test_hours_to_human_mixed():
|
||||
assert hours_to_human(2.5) == "2ч 30м"
|
||||
assert hours_to_human(1.5) == "1ч 30м"
|
||||
|
||||
|
||||
def test_hours_to_human_rounding():
|
||||
assert hours_to_human(3.1666) == "3ч 10м" # 190 минут -> 3ч 10м
|
||||
|
||||
|
||||
def test_get_month_name_from_range():
|
||||
assert get_month_name_from_range("2026-01-01", "2026-01-31") == "Январь"
|
||||
assert get_month_name_from_range("2025-12-01", "2026-02-15") == "Февраль"
|
||||
|
||||
|
||||
def test_get_month_name_from_range_all_months():
|
||||
months = [
|
||||
"Январь",
|
||||
"Февраль",
|
||||
"Март",
|
||||
"Апрель",
|
||||
"Май",
|
||||
"Июнь",
|
||||
"Июль",
|
||||
"Август",
|
||||
"Сентябрь",
|
||||
"Октябрь",
|
||||
"Ноябрь",
|
||||
"Декабрь",
|
||||
]
|
||||
for i, name in enumerate(months, start=1):
|
||||
to_date = f"2026-{i:02d}-01"
|
||||
assert get_month_name_from_range("2026-01-01", to_date) == name
|
||||
|
||||
|
||||
def test_get_month_name_from_range_invalid_fallback():
|
||||
"""Невалидная дата -- возвращается 'Январь'."""
|
||||
assert get_month_name_from_range("invalid", "also_invalid") == "Январь"
|
||||
|
||||
|
||||
def test_get_version_with_attribute():
|
||||
class MockIssue:
|
||||
fixed_version = "v2.5.0"
|
||||
|
||||
assert get_version(MockIssue()) == "v2.5.0"
|
||||
|
||||
|
||||
def test_get_version_without_attribute():
|
||||
class MockIssue:
|
||||
pass
|
||||
|
||||
assert get_version(MockIssue()) == "<N/A>"
|
||||
|
||||
|
||||
def test_get_version_none_attribute():
|
||||
class MockIssue:
|
||||
fixed_version = None
|
||||
|
||||
assert get_version(MockIssue()) == "<N/A>"
|
||||
|
||||
|
||||
def test_get_version_with_redminelib_version_object():
|
||||
"""redminelib Version: str() возвращает ID, .name — человекочитаемое имя."""
|
||||
|
||||
class MockVersion:
|
||||
"""Имитирует redminelib.resources.Version — str() даёт ID."""
|
||||
|
||||
def __init__(self, vid, name):
|
||||
self.id = vid
|
||||
self.name = name
|
||||
|
||||
def __str__(self):
|
||||
return str(self.id)
|
||||
|
||||
class MockIssue:
|
||||
fixed_version = MockVersion(42, "v2.5.0")
|
||||
|
||||
assert get_version(MockIssue()) == "v2.5.0"
|
||||
|
||||
|
||||
def test_get_version_falls_back_to_str_when_no_name():
|
||||
"""Если у объекта версии нет .name — fallback на str()."""
|
||||
|
||||
class MockVersionNoName:
|
||||
def __str__(self):
|
||||
return "fallback-id"
|
||||
|
||||
class MockIssue:
|
||||
fixed_version = MockVersionNoName()
|
||||
|
||||
assert get_version(MockIssue()) == "fallback-id"
|
||||
330
tests/test_yaml_config.py
Normal file
330
tests/test_yaml_config.py
Normal file
@@ -0,0 +1,330 @@
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from redmine_reporter.yaml_config import (
|
||||
check_file_permissions,
|
||||
ensure_config_dir,
|
||||
expand_filename_template,
|
||||
resolve_env_vars,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveEnvVars:
|
||||
"""Tests for ${VAR} resolution."""
|
||||
|
||||
@mock.patch.dict(os.environ, {"MY_VAR": "hello"}, clear=True)
|
||||
def test_replaces_var_with_env_value(self):
|
||||
assert resolve_env_vars("prefix_${MY_VAR}_suffix") == "prefix_hello_suffix"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_missing_var_returns_empty_and_warns(self, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = resolve_env_vars("${MISSING_VAR}")
|
||||
assert result == ""
|
||||
assert "MISSING_VAR" in caplog.text
|
||||
|
||||
def test_no_braces_returns_unchanged(self):
|
||||
assert resolve_env_vars("plain text no vars") == "plain text no vars"
|
||||
|
||||
@mock.patch.dict(os.environ, {"A": "${B}", "B": "final"}, clear=True)
|
||||
def test_non_recursive_no_double_resolution(self):
|
||||
# A → literal "${B}", B → "final". Non-recursive means we get "${B}", not "final".
|
||||
result = resolve_env_vars("${A}")
|
||||
assert result == "${B}"
|
||||
|
||||
|
||||
class TestEnsureConfigDir:
|
||||
"""Tests for config directory creation."""
|
||||
|
||||
def test_creates_dir_with_0700(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_dir = Path(tmp) / "test_config"
|
||||
result = ensure_config_dir(config_dir)
|
||||
assert result == config_dir
|
||||
assert result.exists()
|
||||
assert result.is_dir()
|
||||
mode = result.stat().st_mode & 0o777
|
||||
assert mode == 0o700
|
||||
|
||||
def test_noop_if_dir_exists(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_dir = Path(tmp) / "existing"
|
||||
config_dir.mkdir(mode=0o700)
|
||||
mtime_before = config_dir.stat().st_mtime
|
||||
result = ensure_config_dir(config_dir)
|
||||
assert result == config_dir
|
||||
assert config_dir.stat().st_mtime == mtime_before
|
||||
|
||||
|
||||
class TestCheckFilePermissions:
|
||||
"""Tests for config file permission checks."""
|
||||
|
||||
def test_warns_on_permissive_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Path(tmp) / "open.yml"
|
||||
f.write_text("key: value")
|
||||
f.chmod(0o644)
|
||||
warnings = check_file_permissions(f)
|
||||
assert len(warnings) >= 1
|
||||
assert "0644" in warnings[0] or "permissions" in warnings[0].lower()
|
||||
|
||||
def test_silent_on_0600(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Path(tmp) / "secure.yml"
|
||||
f.write_text("key: value")
|
||||
f.chmod(0o600)
|
||||
warnings = check_file_permissions(f)
|
||||
assert warnings == []
|
||||
|
||||
|
||||
class TestExpandFilenameTemplate:
|
||||
"""Tests for filename template expansion."""
|
||||
|
||||
def test_expands_all_placeholders(self):
|
||||
result = expand_filename_template(
|
||||
"report_{author}_{from}_{to}.{ext}",
|
||||
author="Кокос А.А.",
|
||||
from_date="2026-06-01",
|
||||
to_date="2026-06-30",
|
||||
ext="xlsx",
|
||||
)
|
||||
assert result == "report_Кокос_А.А._2026-06-01_2026-06-30.xlsx"
|
||||
|
||||
def test_date_placeholder_dd_mm_yyyy(self):
|
||||
result = expand_filename_template(
|
||||
"отчёт_{date}.{ext}",
|
||||
author="Кокос А.А.",
|
||||
from_date="2026-03-01",
|
||||
to_date="2026-03-31",
|
||||
ext="odt",
|
||||
)
|
||||
assert result == "отчёт_31_03_2026.odt"
|
||||
|
||||
def test_no_placeholders_returns_unchanged(self):
|
||||
result = expand_filename_template(
|
||||
"report.odt",
|
||||
author="Кокос А.А.",
|
||||
from_date="2026-01-01",
|
||||
to_date="2026-01-31",
|
||||
ext="odt",
|
||||
)
|
||||
assert result == "report.odt"
|
||||
|
||||
def test_unknown_placeholder_left_as_is(self):
|
||||
result = expand_filename_template(
|
||||
"{author}_{unknown}.{ext}",
|
||||
author="Кокос А.А.",
|
||||
from_date="2026-01-01",
|
||||
to_date="2026-01-31",
|
||||
ext="xlsx",
|
||||
)
|
||||
assert result == "Кокос_А.А._{unknown}.xlsx"
|
||||
|
||||
|
||||
class TestResolveOutputPath:
|
||||
"""Tests for resolve_output_path()."""
|
||||
|
||||
def test_none_returns_none(self):
|
||||
from redmine_reporter.yaml_config import resolve_output_path
|
||||
|
||||
assert resolve_output_path(None) is None
|
||||
|
||||
def test_explicit_path_with_known_extension_is_unchanged(self):
|
||||
from redmine_reporter.yaml_config import resolve_output_path
|
||||
|
||||
result = resolve_output_path("/tmp/report.xlsx")
|
||||
assert result == "/tmp/report.xlsx"
|
||||
|
||||
def test_explicit_path_with_unknown_extension_is_unchanged(self):
|
||||
from redmine_reporter.yaml_config import resolve_output_path
|
||||
|
||||
result = resolve_output_path("report.odt")
|
||||
assert result == "report.odt"
|
||||
|
||||
def test_no_extension_appends_default_format(self):
|
||||
from redmine_reporter.yaml_config import resolve_output_path
|
||||
|
||||
result = resolve_output_path("report", default_format="xlsx")
|
||||
assert result == "report.xlsx"
|
||||
|
||||
def test_no_extension_appends_custom_default_format(self):
|
||||
from redmine_reporter.yaml_config import resolve_output_path
|
||||
|
||||
result = resolve_output_path("file", default_format="csv")
|
||||
assert result == "file.csv"
|
||||
|
||||
def test_bare_format_name_resolves_to_template_path(self):
|
||||
from redmine_reporter.yaml_config import resolve_output_path
|
||||
|
||||
result = resolve_output_path(
|
||||
"xlsx",
|
||||
output_dir="/tmp/reports",
|
||||
filename_template="{date}.{ext}",
|
||||
author="Кокос А.А.",
|
||||
from_date="2026-06-01",
|
||||
to_date="2026-06-30",
|
||||
default_format="xlsx",
|
||||
)
|
||||
assert result == "/tmp/reports/30_06_2026.xlsx"
|
||||
|
||||
def test_bare_format_name_odt_resolves_to_template_path(self):
|
||||
from redmine_reporter.yaml_config import resolve_output_path
|
||||
|
||||
result = resolve_output_path(
|
||||
"odt",
|
||||
output_dir="~/reports",
|
||||
filename_template="{author}_{from}_{to}.{ext}",
|
||||
author="Кокос А.А.",
|
||||
from_date="2026-06-01",
|
||||
to_date="2026-06-30",
|
||||
default_format="xlsx",
|
||||
)
|
||||
assert result.startswith("/") and result.endswith(
|
||||
"/reports/Кокос_А.А._2026-06-01_2026-06-30.odt"
|
||||
)
|
||||
assert "~" not in result
|
||||
|
||||
def test_bare_format_name_with_empty_output_dir_uses_cwd(self):
|
||||
from redmine_reporter.yaml_config import resolve_output_path
|
||||
|
||||
result = resolve_output_path(
|
||||
"csv",
|
||||
output_dir="",
|
||||
filename_template="report.{ext}",
|
||||
author="A",
|
||||
from_date="2026-01-01",
|
||||
to_date="2026-01-31",
|
||||
)
|
||||
assert not result.startswith("/")
|
||||
assert "report.csv" in result
|
||||
|
||||
def test_unknown_bare_format_is_treated_as_path(self):
|
||||
from redmine_reporter.yaml_config import resolve_output_path
|
||||
|
||||
result = resolve_output_path("unknown_format")
|
||||
assert result == "unknown_format.xlsx"
|
||||
|
||||
|
||||
class TestSavePeriodToConfig:
|
||||
"""Tests for save_period_to_config()."""
|
||||
|
||||
def test_creates_last_used_in_empty_config(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
save_period_to_config(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||
)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30"
|
||||
assert "default_from" not in data["period"]
|
||||
|
||||
def test_overwrites_existing_last_used(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
"period:\n last_used:\n from: '2026-05-01'\n to: '2026-05-31'\n"
|
||||
)
|
||||
|
||||
save_period_to_config(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||
)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30"
|
||||
|
||||
def test_dynamic_false_overwrites_defaults(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
"period:\n default_from: '2026-01-01'\n default_to: '2026-01-31'\n"
|
||||
)
|
||||
|
||||
save_period_to_config(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", False
|
||||
)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["default_from"] == "2026-06-01"
|
||||
assert data["period"]["default_to"] == "2026-06-30"
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30"
|
||||
|
||||
def test_datetime_precision_stores_timestamps(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
|
||||
save_period_to_config(
|
||||
str(config_path),
|
||||
"2026-06-30T09:00:00",
|
||||
"2026-06-30T12:00:00",
|
||||
"datetime",
|
||||
True,
|
||||
)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-30T09:00:00"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30T12:00:00"
|
||||
|
||||
def test_preserves_existing_sections(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
"redmine:\n"
|
||||
" url: https://example.com\n"
|
||||
" author: Test\n"
|
||||
"output:\n"
|
||||
" dir: /tmp\n"
|
||||
)
|
||||
|
||||
save_period_to_config(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||
)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["redmine"]["url"] == "https://example.com"
|
||||
assert data["redmine"]["author"] == "Test"
|
||||
assert data["output"]["dir"] == "/tmp"
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
|
||||
def test_sets_permissions_0600(self, tmp_path):
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
|
||||
save_period_to_config(
|
||||
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||
)
|
||||
|
||||
mode = config_path.stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
Reference in New Issue
Block a user