64 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
b926dd0d49 chore: bump version to 1.9.0 2026-07-07 11:11:58 +07:00
Кокос Артем Николаевич
80faccb1f9 docs: add --commit documentation to README and CONFIG 2026-07-07 10:48:36 +07:00
Кокос Артем Николаевич
9b78d66769 feat: --commit flag (#44)
- --commit: после генерации отчёта сохраняет период в
  period.last_used.from/to YAML-конфига
- При period.dynamic=true следующий запуск вычисляет период от last_used:
  полный месяц → следующий месяц, произвольный диапазон → та же длина
- При period.dynamic=false перезаписывает default_from/default_to
- При period.precision=datetime сохраняет timestamps с точностью до секунд
- --commit без --output сохраняет отчёт по шаблону из конфига
- compute_next_period() в config.py
- save_period_to_config() в yaml_config.py
- 23 новых теста (7 CLI, 6 config, 6 yaml, 4 date_range)

Closes #44
2026-07-07 10:47:08 +07:00
Кокос Артем Николаевич
485be063d2 docs: update README and CONFIG for #43 #47
- README: add YAML config section, --by-activity, bare format --output,
  output without extension, period.precision
- CONFIG: document period.last_used, period.precision (date/datetime),
  output path resolution rules, resolve_output_path() behavior
2026-07-07 10:34:49 +07:00
Кокос Артем Николаевич
47152f8f04 feat: output path defaults (#43) + datetime precision & dedup (#47)
#43 — Имя файла и пути по умолчанию:
- resolve_output_path() в yaml_config.py: резолвит --output с учётом
  output.dir, filename_template, default_format из YAML-конфига
- Bare format (xlsx/odt/...) → путь по шаблону
- Без расширения → автодописывается default_format (.xlsx)
- Config.get_output_dir/filename/default_format()

#47 — datetime precision и дедупликация:
- period.last_used.from/to в YAML-конфиге и AppConfig
- period.precision: date|datetime
- _compute_dedup_cutoff() в cli.py: при precision=datetime вычисляет
  cutoff из period.last_used.to
- _parse_datetime() + AND-логика дедупликации в client.py:
  запись исключается если created_on И updated_on < cutoff
- Пропуск issues без часов после дедупликации

Closes #43
Closes #47
2026-07-07 10:16:46 +07:00
Кокос Артем Николаевич
67350bfcd6 fix: verify_ssl: true in YAML now uses DEFAULT_REDMINE_VERIFY path
Previously boolean True was passed directly to python-redmine
which used system CA bundle instead of the project's default CA file.
Now 'true' in YAML maps to DEFAULT_REDMINE_VERIFY path.
2026-07-03 18:24:11 +07:00
Кокос Артем Николаевич
0fa4e271a7 fix: auto-load YAML config on CLI startup
CLI now reads ~/.config/redmine-reporter/config.yml automatically.
Previously Config.load_yaml() existed but was never called.
2026-07-03 18:19:30 +07:00
Кокос Артем Николаевич
b1a565bc9e feat: filename template expansion with {date} placeholder
- Add expand_filename_template() to yaml_config.py
- Supports {author}, {from}, {to}, {date}, {ext} placeholders
- {date} formats as DD_MM_YYYY (e.g. 31_03_2026)
- Spaces in author replaced with underscores for safe filenames
- Unknown placeholders left as-is
- Add comprehensive CONFIG.md documentation covering YAML setup, migration, and template syntax
2026-07-03 18:13:55 +07:00
Кокос Артем Николаевич
c962a93f30 feat: YAML config support (~/.config/redmine-reporter/config.yml)
- Add AppConfig/SmtpConfig/EmailConfig dataclasses with from_yaml()/from_env()
- Add yaml_config.py: ${VAR} resolver, 0700/0600 permission helpers
- Config.get_*() methods gain YAML fallback in priority chain
- Priority: CLI > env > .env > YAML > code defaults
- CLI --init-config generates YAML from current environment
- --force flag allows overwriting existing config
- Secrets default to ${VAR} references, plaintext allowed
- Full backward compatibility with existing .env setups

Closes #46
2026-07-03 18:00:28 +07:00
Кокос Артем Николаевич
676f7ede30 fix: bump __version__ to 1.8.1 2026-06-30 09:48:37 +07:00
Кокос Артем Николаевич
5dd234e7b3 fix(odt): remove stray blank paragraph from template (#42)
The template contained an empty <text:p/> that was rendered as
a blank line before the report header.  After loading the template,
strip all text:p children so that ODT output no longer depends on
template editing artifacts.

Closes #42.
2026-06-30 09:43:08 +07:00
Кокос Артем Николаевич
59af7ce464 feat(activity): add --by-activity flag to break down spent time by activity type
- Load time entry activity names from Redmine enumeration.
- Aggregate hours per issue and per activity in fetch_issues_with_spent_time.
- Add --by-activity CLI flag and propagate it through report builder,
  summary, and all formatters (console, CSV, HTML, JSON, ODT).
- Keep backward-compatible 2-tuple input in build_grouped_report.
- Bump version to 1.8.0.

Closes #41
2026-06-29 16:44:52 +07:00
Кокос Артем Николаевич
f6861382e6 feat(user): report on another user's time entries
- Add user_id parameter to fetch_issues_with_spent_time().
- Support numeric ID, login, or full name resolution.
- Reject ambiguous names and unknown users with clear messages.
- Add CLI flags: --user-id, --user-login, --user-name.
- Only allow one user flag at a time.
- Add ResourceNotFoundError handling.
- Update README with usage examples.
- Add tests for user resolution and CLI flags.

Closes #40
2026-06-29 15:15:17 +07:00
Кокос Артем Николаевич
67b5d093d9 docs(readme): update README and bump version to 1.6.1
- Remove duplicated --config bullet.
- Update Excel section to reflect production-grade XLSX features.
- Add --no-time note for file formats.
- Add readable API error messaging to feature list.
- Fix development commands: use --check flags and add mypy.
- Replace verbose per-format sections with a summary table.
- Bump pyproject.toml version to 1.6.1.
2026-06-29 15:02:31 +07:00
Кокос Артем Николаевич
f80f3a8b52 feat(errors): provide readable Redmine API error messages
- Introduce RedmineAPIError with human-friendly messages.
- Distinguish AuthError, ForbiddenError, HTTP status codes, timeouts
  and connection errors in client.py.
- Update CLI to print the readable message instead of generic
  "Redmine API error: ...".
- Log original exception with traceback when --debug is enabled.
- Add tests for all error paths and CLI output.

Closes #39
2026-06-29 14:58:42 +07:00
Кокос Артем Николаевич
222d31730e feat(xlsx): make Excel export production-grade
- Add merge cells for project/version groups.
- Add numeric Hours column and human-readable Spent Time.
- Add version/project/grand totals.
- Apply auto-width, freeze panes, auto-filter and styling.
- Respect --no-time: keep columns empty, skip totals.
- Pass no_time flag through formatter factory to all file formatters.
- Add tests for XLSX features and --no-time behavior.

Closes #38
2026-06-29 14:41:37 +07:00
Кокос Артем Николаевич
86f083aa79 chore(release): bump version to 1.6.0
Bump version from 1.5.0 to 1.6.0 in __init__.py and pyproject.toml.
Update test_cli_version_flag to assert against the current package
version instead of a hardcoded string.
2026-06-29 12:38:54 +07:00
Кокос Артем Николаевич
a82be05b83 fix: handle empty time entries and add missing mypy override for openpyxl
- Treat empty list from fetch_issues_with_spent_time as 'no entries' and exit 0
  instead of crashing later in the pipeline.
- Update CLI output-extension tests to use non-empty mock data so they actually
  reach the extension validation code path.
- Add openpyxl to mypy ignore_missing_imports overrides.

Closes #36
2026-06-29 12:21:44 +07:00
Кокос Артем Николаевич
ca89832d74 feat: JSON, Excel export and time summary
- #23: add JSONFormatter and XLSXFormatter
- add openpyxl dependency for .xlsx export
- #22: add --summary flag and calculate_summary() in report_builder
- ReportRow now carries raw hours for summary calculations
- update CLI help and README with .json/.xlsx formats and --summary
- add tests for new formatters and summary computation

Closes #22, closes #23
2026-06-29 12:09:58 +07:00
Кокос Артем Николаевич
f6afc4096d feat(cli): dynamic default range, --version, --verbose, --debug, --url, --api-key, --config
- #17: default date range falls back to current month when .env dates are missing
- #18: add --version, --verbose, --debug flags
- #20: add --url, --api-key, --config CLI overrides
- Config supports CLI overrides for URL/API key and explicit config file loading
- Update README with new CLI options
- Add tests for new flags and config overrides

Closes #17, closes #18, closes #20
2026-06-29 12:02:51 +07:00
Кокос Артем Николаевич
738d9d543e fix: mount retry adapter on Redmine engine session
python-redmine exposes  as a context manager, while the
actual requests.Session lives in . Mounting the
retry adapter on the wrong object caused:

    'function' object has no attribute 'mount'

on startup. Update the related test to mock the real session.
2026-06-29 11:53:09 +07:00
Артём Кокос
58fa5a7ab4 Add retry/backoff, timeout, and issue_id chunking to Redmine client
- #24: Mount HTTPAdapter with Retry(total=3, backoff_factor=0.5) for
  HTTP 429/500/502/503/504 on the Redmine session, set 30s timeout
- #21: Split large issue_id lists into chunks of 100 to avoid exceeding
  URL length limits on reverse proxies

Closes #24, #21
2026-06-27 13:06:41 +07:00
Артём Кокос
14219564dd Fix 9 bugs: ODT covered-cells, CSV BOM, HTML charset, stderr, dead code
- #13 (critical): Add CoveredTableCell elements to ODT for valid row spans (ODF 1.2)
- #28: Move "Total issues" info message from stdout to stderr (clean pipe output)
- #27: Wrap HTML export in full document with DOCTYPE and meta charset utf-8
- #26: Save CSV with utf-8-sig encoding (UTF-8 BOM for Excel compatibility)
- #31: Document CSV uses full project/version values (not display_* like console/MD)
- #30: Fix ODT header formatting when author is empty (no leading dot/space)
- #36: Remove test_cli_smoke_empty testing unreachable code path (return [])
- #37: Remove unused mock_path variable in ODT test fixture
- #34: Remove unreachable len(parts) != 2 check in parse_date_range

Closes #13, #28, #27, #26, #31, #30, #36, #37, #34
2026-06-27 13:01:32 +07:00
Артём Кокос
da069993b9 Extract grouping logic into shared function (#19)
Add group_rows_by_project_and_version() to report_builder.py and replace
the duplicated inline grouping in both html.py and odt.py with a single
call to the shared function. This ensures consistent grouping behavior
across formatters and removes a maintenance burden — any change to
grouping logic now happens in one place.

Closes #19
2026-06-26 00:21:58 +07:00
Артём Кокос
dbc4cf960a Fix version display, deterministic sort, remove duplicate sort
get_version() now returns version.name instead of str(version), which
returned the numeric ID from redminelib.resources.Version. Falls back
to str() when .name is absent (#14).

Add issue.id as tertiary sort key in build_grouped_report so tasks
within the same project+version group always appear in the same order
regardless of API response ordering (#33).

Remove redundant sort from client.fetch_issues_with_spent_time — the
sort already runs in report_builder.build_grouped_report, so doing it
twice wastes CPU. Also remove the now-unused get_version import (#32).

Closes #14, closes #32, closes #33
2026-06-25 23:32:14 +07:00
Артём Кокос
3a6d1b7ba7 Fix .env override priority and password whitespace stripping
Change load_dotenv() to load_dotenv(override=False) so real environment
variables always take priority over stale .env files. This prevents a
hardcoded or outdated REDMINE_API_KEY in .env from silently overriding
a fresh token exported in the shell (#15).

Add .strip() to get_redmine_password() for consistency with all other
config getters — trailing whitespace in .env no longer causes silent
auth failures (#35).

Closes #15, closes #35
2026-06-25 21:06:19 +07:00
Артём Кокос
3956decd4e Fix package structure and lazy-import ODT formatter
Add missing __init__.py to formatters/ so setuptools.find_packages
includes the subpackage in wheel/sdist builds (#16).

Move ODTFormatter import from top-level to lazy import inside
get_formatter_by_extension() so missing odfpy no longer crashes module
load before main() runs. Remove dead except ImportError handler in
cli.py save() block; surface a clear 'odfpy is not installed' message
when the formatter factory returns None for .odt (#25).

Closes #16, closes #25
2026-06-25 20:42:13 +07:00
Кокос Артем Николаевич
0e4e0f3ee2 Bump version: v1.5.0 2026-05-22 17:49:18 +07:00
Кокос Артем Николаевич
2db0ab1f0b Tighten configuration and export handling 2026-05-22 17:41:56 +07:00
Кокос Артем Николаевич
8bc8181ce3 Add Redmine API token authentication 2026-05-22 17:19:00 +07:00
Artem Kokos
7bc6e024c0 Quick fixes & tests 2026-03-28 23:55:46 +07:00
Кокос Артем Николаевич
06cd57e2c4 Blacked 2026-02-05 15:31:31 +07:00
Кокос Артем Николаевич
d7e927e6eb Bump version: v1.4.1 2026-02-05 15:18:40 +07:00
Кокос Артем Николаевич
000bf37503 Update status map 2026-02-05 15:15:44 +07:00
Артём Кокос
dfb8d474b4 Update README.md 2026-01-26 21:03:24 +07:00
Кокос Артем Николаевич
b7f03666dc Bump version: v1.4.0 2026-01-26 13:43:54 +07:00
Кокос Артем Николаевич
30310d614d Add HTML formatter
Closes #7
2026-01-26 12:57:33 +07:00
Кокос Артем Николаевич
ad62ef4f6c Fix CSV & MD 2026-01-26 12:56:57 +07:00
Кокос Артем Николаевич
355849e004 Fix issue mock for tests 2026-01-26 12:37:57 +07:00
Артём Кокос
d839be8776 Add unit-tests 2026-01-25 12:40:23 +07:00
Артём Кокос
ea90fe79d0 ODT template into resources 2026-01-25 12:24:32 +07:00
Артём Кокос
1f77088c21 Add tests for all formatters 2026-01-25 11:19:04 +07:00
Артём Кокос
8278864b01 Formatters factory 2026-01-25 11:01:34 +07:00
Кокос Артем Николаевич
f858618a13 Bump version: 1.3.0 2026-01-24 16:10:03 +07:00
Кокос Артем Николаевич
e344715f61 feat(formatter): unify data pipeline with ReportRow and report_builder 2026-01-24 16:09:25 +07:00
Кокос Артем Николаевич
245ea0a3fa Add Markdown format
Closes #6
2026-01-22 16:52:18 +07:00
Кокос Артем Николаевич
2a39de467f Add CSV format support
Closes #5
2026-01-22 16:42:39 +07:00
Кокос Артем Николаевич
6416df481e Optional disable fill time for console output too 2026-01-22 12:47:31 +07:00
Кокос Артем Николаевич
ead6c72d16 Optional disable fill time column 2026-01-22 12:38:06 +07:00
Кокос Артем Николаевич
e7efda232c Add helper text into ODT output 2026-01-22 12:07:43 +07:00
Кокос Артем Николаевич
937885a12b Bump version: 1.2.0 2026-01-22 10:59:48 +07:00
Кокос Артем Николаевич
932dd1198a Update .gitignore: ignore report.odt always 2026-01-22 10:58:42 +07:00
Кокос Артем Николаевич
0bff2363dc Add empty line after title 2026-01-22 10:55:42 +07:00
Кокос Артем Николаевич
9b260b27fd Use right cells style in ODT table 2026-01-22 10:45:37 +07:00
Кокос Артем Николаевич
a8511368ce Fix cells width 2026-01-21 18:02:00 +07:00
Кокос Артем Николаевич
7a8b629c7c Bump version: 1.1.0 2026-01-21 14:23:06 +07:00
Кокос Артем Николаевич
41c7ef24a3 Update README.md 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
5b813c76e9 Use last month in ODT-report 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
5a5ee00726 Config: Report author name 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
4a5dee7a14 Update template with table-styles 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
2c123e9ae7 ODT: Group by project & versions 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
9a2f753480 Album orient by using templ. doc 2026-01-21 14:22:18 +07:00
Artem Kokos
bca24189c7 ODT table support 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
6fcc834617 Add lic 2026-01-21 10:44:53 +07:00
32 changed files with 6011 additions and 252 deletions

7
.gitignore vendored
View File

@@ -85,3 +85,10 @@ secrets.json
# Temporary files
*.tmp
*.bak
# Just in case
.~*
# Local report outputs
report.*
rep.*
*.html

21
LICENSE Normal file
View 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.

275
README.md
View File

@@ -1,132 +1,247 @@
# redmine-reporter
Инструмент для генерации отчётов по задачам в Redmine на основе ваших записей о затраченном времени.
CLI-инструмент для генерации отчётов по задачам Redmine на основе записей о затраченном времени.
> Предназначен для внутреннего использования в Eltex. Работает с `https://red.eltex.loc/`.
Проект предназначен для внутреннего использования с `https://red.eltex.loc/`.
---
Лицензия: MIT.
## 🔧 Возможности
## Возможности
- Безопасная передача учётных данных через переменные окружения или `.env`
- Два режима вывода: компактный (для копирования) и табличный (для просмотра)
- Группировка задач по проекту и версии
- Перевод статусов на русский язык
- Простой CLI с понятными аргументами
- Поддержка настройки диапазона дат по умолчанию через `.env`
- Получение time entries **текущего** или **указанного** пользователя из Redmine.
- Авторизация через Redmine API token или логин/пароль.
- Группировка задач по проекту и версии.
- Перевод статусов задач на русский язык.
- Разбивка по типам активности (`--by-activity`).
- Вывод в консоль (таблица / компактный вид).
- Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
- Excel-отчёт с merge-ячейками по проекту/версии, итогами, автошириной, автофильтром и закреплённой шапкой.
- Сводка по времени (`--summary`).
- YAML-конфиг (`~/.config/redmine-reporter/config.yml`): шаблон имени файла, путь по умолчанию, период, SMTP.
- Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание.
- `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска.
- Понятные сообщения об ошибках Redmine API (401/403/5xx, таймаут, сеть).
- Загрузка альтернативного `.env` через `--config`.
---
## 🚀 Установка и настройка (продакшен)
### 1. Клонируйте репозиторий
## Установка
```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)
pip install -e ".[dev]"
```
> ✅ Это безопасно: вы просто добавляете доверенные системные сертификаты к Python.
> Не используйте `verify=False` — это создаёт уязвимость.
## Настройка
### 4. Настройте учётные данные и (опционально) даты
Источники конфигурации (от высшего приоритета к низшему):
Создайте файл `.env` в корне проекта (**никогда не коммитьте его!**):
```
CLI-флаги > переменные окружения > .env > YAML-конфиг > кодовые дефолты
```
### YAML-конфиг (основной способ)
```bash
# Сгенерировать YAML из текущего .env
redmine-reporter --init-config
# Редактировать под себя
vim ~/.config/redmine-reporter/config.yml
```
Структура:
```yaml
redmine:
url: https://red.eltex.loc
api_key: ${REDMINE_API_KEY}
author: "Кокос А.А."
verify_ssl: true
period:
precision: date # date | datetime
default_from: "2026-06-01"
default_to: "2026-06-30"
dynamic: false
# last_used заполняется --commit (см. docs/CONFIG.md)
output:
dir: ~/reports
filename: "{author}_{from}_{to}.{ext}"
default_format: xlsx
email:
smtp:
host: smtp.example.com
port: 587
user: bot@example.com
password: ${SMTP_PASSWORD}
tls: true
from: bot@example.com
to:
- boss@example.com
subject: "Отчёт {author} за {period}"
```
Шаблон `output.filename` поддерживает `{author}`, `{from}`, `{to}`, `{date}` (DD_MM_YYYY), `{ext}`.
Подробнее: [docs/CONFIG.md](docs/CONFIG.md).
### `.env` (legacy)
```ini
REDMINE_URL=https://red.eltex.loc/
REDMINE_USER=ваш.логин
REDMINE_PASSWORD=ваш_пароль
# Опционально: диапазон дат по умолчанию
REDMINE_API_KEY=ваш_api_token
REDMINE_AUTHOR=Иванов Иван Иванович
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
```
| Переменная | Обязательность | Описание |
| --- | --- | --- |
| `REDMINE_URL` | Да | URL Redmine. |
| `REDMINE_API_KEY` | Да, если нет логина и пароля | Redmine API token. |
| `REDMINE_USER` | Да, если нет токена | Логин Redmine. |
| `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. |
| `REDMINE_AUTHOR` | Нет | Имя автора для отчёта. |
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). |
| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию (`YYYY-MM-DD`). |
| `REDMINE_VERIFY` | Нет | TLS-проверка: `true` / `false` / путь к CA bundle. |
> 🔐 Рекомендуется использовать аккаунт с минимальными правами (только чтение time entries и задач).
> 💡 Если `DEFAULT_FROM_DATE` и `DEFAULT_TO_DATE` не заданы, используется встроенный диапазон: `2025-12-19--2026-01-31`.
---
## ▶️ Использование
Перед каждым запуском активируйте окружение:
## Использование
```bash
source .venv/bin/activate
```
Затем:
Отчёт за период по умолчанию:
```bash
# Отчёт за период по умолчанию (из .env или встроенный)
redmine-reporter
```
Произвольный период:
```bash
redmine-reporter --date 2026-02-01--2026-02-28
```
Другой пользователь:
```bash
redmine-reporter --user-id 42
redmine-reporter --user-login ivanov
redmine-reporter --user-name "Иванов И.И."
```
Переопределить URL / API-ключ:
```bash
redmine-reporter --url https://red.example.com --api-key ваш_токен
```
Альтернативный `.env`:
```bash
redmine-reporter --config /path/to/.env
```
Компактный / отладочный вывод:
```bash
redmine-reporter --compact
redmine-reporter --debug
```
Экспорт с явным путём:
```bash
redmine-reporter --output report.xlsx
redmine-reporter --output /path/to/report.odt
```
Экспорт — только формат (путь и имя берутся из YAML-шаблона):
```bash
redmine-reporter --output xlsx # → output.dir/отчёт_01_07_2026.xlsx
redmine-reporter --output odt # → output.dir/отчёт_01_07_2026.odt
```
Экспорт — путь без расширения (дописывается `default_format` из конфига):
```bash
redmine-reporter --output /tmp/report # → /tmp/report.xlsx (если default_format: xlsx)
```
Без времени / с разбивкой по активностям:
```bash
redmine-reporter --no-time
redmine-reporter --by-activity
redmine-reporter --by-activity --summary
```
Сводка:
```bash
redmine-reporter --summary
```
Фиксация периода (`--commit`):
```bash
# Сгенерировать, сохранить в файл по шаблону, запомнить период
redmine-reporter --commit
# С явным путём
redmine-reporter --commit --output report.xlsx
# Следующий запуск (без --date) возьмёт следующий период автоматически
redmine-reporter
# Отчёт за произвольный период (переопределяет .env)
redmine-reporter --date 2026-02-01--2026-02-28
# Компактный вывод (удобно копировать в письмо)
redmine-reporter --compact
# При precision=datetime запоминает момент времени
# (предотвращает дублирование записей внутри дня)
redmine-reporter --commit
```
Пример вывода:
```
✅ Total issues: 7 [2026-01-01--2026-01-31]
╒════════════╤═══════════╤══════════════════════════════════════╤═══════════╤════════════╕
│ Проект │ Версия │ Задача │ Статус │ Затрачено │
╞════════════╪═══════════╪══════════════════════════════════════╪═══════════╪════════════╡
│ Камеры │ v2.5.0 │ 12345. Поддержка нового датчика │ В работе │ 2.00h │
│ │ │ 12346. Исправить утечку памяти │ Решена │ 2.00h │
│ ПО │ <N/A> │ 12350. Обновить документацию │ Ожидание │ 12.00h │
╘════════════╧═══════════╧══════════════════════════════════════╧═══════════╧════════════╛
```
## Форматы вывода
---
| Формат | Особенности |
| --- | --- |
| **ODT** | Заголовок с автором и месяцем, группировка по проекту/версии. |
| **CSV** | UTF-8 с BOM, полные значения `project`/`version` в каждой строке. |
| **Markdown** | Компактная таблица, повторяющиеся группы скрыты. |
| **HTML** | Полноценный HTML-документ с `meta charset="utf-8"`. |
| **JSON** | Массив объектов: `project`, `version`, `issue_id`, `subject`, `status`, `time`. |
| **Excel (.xlsx)** | Merge cells, колонки `Hours`/`Spent Time`, итоги, автоширина, автофильтр, freeze panes. |
## 🛠 Разработка
## Разработка
Для участия в разработке:
Проверки перед коммитом:
```bash
pip install -e ".[dev]"
pytest
black .
isort .
ruff check redmine_reporter tests
black --check redmine_reporter tests
isort --check-only redmine_reporter tests
mypy redmine_reporter
```
---
## Безопасность
> 🔒 **Важно**:
> - Никогда не коммитьте `.env`, пароли или логины.
> - Файл `.gitignore` уже исключает все чувствительные артефакты.
> - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine.
- Не коммитьте `.env`, API token, пароль или логин.
- YAML-конфиг имеет права `0600`, директория — `0700`.
- Рекомендуется хранить секреты через `${VAR}`, а не plaintext.
- Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач.
- Инструмент работает только в режиме чтения и не изменяет данные в Redmine.

259
docs/CONFIG.md Normal file
View File

@@ -0,0 +1,259 @@
# Настройка redmine-reporter
## Источники конфигурации
Приоритет, от высшего к низшему:
```
CLI-флаги > переменные окружения > .env > YAML > кодовые дефолты
```
Если значение не задано на верхнем уровне, берётся уровень ниже. `.env` и YAML
работают одновременно — можно оставить оба, можно удалить `.env` после миграции.
## YAML-конфиг
Основной файл: `~/.config/redmine-reporter/config.yml`.
Создаётся с правами `0600` (владелец: чтение/запись, остальные: доступ запрещён).
Директория `~/.config/redmine-reporter/`с правами `0700`.
Если права файла шире `0600`, при запуске выводится предупреждение.
### Структура
```yaml
redmine:
url: https://red.eltex.loc
api_key: ${REDMINE_API_KEY}
author: "Кокос А.А."
verify_ssl: true
period:
precision: date
default_from: "2026-06-01"
default_to: "2026-06-30"
dynamic: false
last_used:
from: "2026-06-30T09:00:00"
to: "2026-06-30T12:00:00"
output:
dir: ~/reports
filename: "{author}_{from}_{to}.{ext}"
default_format: xlsx
email:
smtp:
host: smtp.example.com
port: 587
user: bot@example.com
password: ${SMTP_PASSWORD}
tls: true
from: bot@example.com
to:
- boss@example.com
cc: []
bcc: []
subject: "Отчёт {author} за {period}"
body_text: "Во вложении отчёт."
attach: true
```
### `period.precision` — точность периода
Определяет, как вычисляется следующий период после фиксации:
- `date` (по умолчанию) — период с точностью до дня. Следующий запуск (после `--commit`) начинается со следующего дня.
- `datetime` — период с точностью до секунды. При повторном запуске time entries с `created_on` и `updated_on` ранее `last_used.to` исключаются (AND-логика: запись исключается только если **оба** поля раньше cutoff). Это предотвращает дублирование при отправке отчёта внутри рабочего дня.
`last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется.
### `--commit` — автофиксация периода
Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода.
**Что делает:**
1. Генерирует отчёт как обычно.
2. Сохраняет отчёт в файл:
- Если указан `--output` — по явному пути.
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
3. Записывает `period.last_used.from` / `period.last_used.to` в YAML-конфиг.
4. При `period.precision: datetime` сохраняет текущий момент времени (ISO с секундами).
5. При `period.precision: date` сохраняет даты периода.
**Поведение `period.dynamic`:**
- `dynamic: true` — следующий запуск (без `--date`) вычисляет период от `last_used`:
- Полный календарный месяц → следующий полный месяц.
- Произвольный диапазон → та же длительность, начиная со дня после `last_used.to`.
- `dynamic: false``--commit` перезаписывает `default_from`/`default_to` на использованный период.
**Примеры:**
```bash
# Июнь 2026 → следующий запуск (без --date) → июль 2026
redmine-reporter --date 2026-06-01--2026-06-30 --commit
# Произвольный диапазон: 15-20 июня → следующий запуск → 21-26 июня
redmine-reporter --date 2026-06-15--2026-06-20 --commit
# С datetime-точностью: повторный запуск не дублирует записи
redmine-reporter --commit
```
### `output` — путь и имя файла по умолчанию
Секция управляет тем, куда и с каким именем сохраняется отчёт, когда `--output` не содержит полного пути.
**Правила разрешения `--output`:**
| Аргумент `--output` | Поведение |
|---|---|
| `/полный/путь/report.xlsx` | Используется как есть, конфиг игнорируется |
| `xlsx` (bare format: `xlsx`, `odt`, `csv`, `md`, `html`, `json`) | Путь = `output.dir` + `output.filename`, расширение = bare format |
| `/tmp/report` (путь без расширения) | Дописывается `.default_format``/tmp/report.xlsx` |
**Шаблон имени файла:**
`output.filename` поддерживает подстановки:
| Плейсхолдер | Описание | Пример |
|---|---|---|
| `{author}` | Имя автора (пробелы → `_`) | `Кокос_А.А.` |
| `{from}` | Начало периода, `YYYY-MM-DD` | `2026-06-01` |
| `{to}` | Конец периода, `YYYY-MM-DD` | `2026-06-30` |
| `{date}` | Конец периода, `DD_MM_YYYY` | `30_06_2026` |
| `{ext}` | Расширение файла без точки | `xlsx` |
Неизвестные плейсхолдеры остаются в имени как есть.
Примеры:
```yaml
# По умолчанию
filename: "{author}_{from}_{to}.{ext}" # → Кокос_А.А._2026-06-01_2026-06-30.xlsx
# Русский формат даты
filename: "отчёт_{date}.{ext}" # → отчёт_30_06_2026.xlsx
# Без автора, только диапазон
filename: "report_{from}--{to}.{ext}" # → report_2026-06-01--2026-06-30.xlsx
```
### Подстановка переменных окружения
В любом строковом значении YAML можно использовать `${VAR}` — при загрузке
оно заменяется на значение переменной окружения:
```yaml
redmine:
api_key: ${REDMINE_API_KEY}
email:
smtp:
password: ${SMTP_PASSWORD}
```
Это безопаснее, чем хранить секреты plaintext в YAML. Однако plaintext-секреты
**не запрещены** — если вписать `api_key: "abc123"` напрямую, система примет.
Права `0600` — основная защита.
## Разрешение выходного пути
Функция `resolve_output_path()` определяет итоговый путь к файлу:
1. `--output` не указан → консольный вывод.
2. `--output xlsx` (bare format) → путь формируется как `output.dir / output.filename` с подстановкой `{ext}` = bare format и дат из периода.
3. `--output /path/report` (без расширения) → дописывается `.output.default_format`.
4. `--output /path/report.csv` (с расширением) → используется как есть.
5. `--output /path/report.xyz` (неизвестное расширение) → используется как есть, форматтер выбирается по расширению.
## Миграция с `.env` на YAML
### Быстрый старт
```bash
# 1. Генерируем YAML из текущего .env
redmine-reporter --init-config
# 2. Проверяем, что создалось
cat ~/.config/redmine-reporter/config.yml
# 3. Редактируем под себя (шаблон имени, период, etc.)
vim ~/.config/redmine-reporter/config.yml
```
### Что делает `--init-config`
- Читает текущие значения из `.env` и переменных окружения.
- Формирует YAML со всеми секциями (`redmine`, `period`, `output`, `email`).
- Секреты (`REDMINE_API_KEY`, `SMTP_PASSWORD`) записывает как `${VAR}`, если
переменная существует, иначе — пустая строка.
- Создаёт файл с правами `0600`, директорию — с `0700`.
### Флаги миграции
| Флаг | Назначение |
|---|---|
| `--init-config` | Создать YAML и выйти |
| `--init-config --force` | Перезаписать существующий YAML |
| `--config-path PATH` | Сохранить YAML по указанному пути (по умолчанию `~/.config/redmine-reporter/config.yml`) |
### Проверка после миграции
```bash
# Запустить без .env в текущей директории
cd /tmp
redmine-reporter --date 2026-06-01--2026-06-30
```
Если отработал — YAML-конфиг читается корректно. Если `REDMINE_URL is required`
проверь права:
```bash
ls -la ~/.config/redmine-reporter/
# config.yml должно быть -rw------- (600)
# директория должна быть drwx------ (700)
```
### Сосуществование `.env` и YAML
Можно оставить оба источника. `.env` имеет **более высокий приоритет**, чем YAML:
```
.env значения переопределяют YAML, если заданы
YAML работает как базовый слой для всего, что не в .env
```
Это safe — если с YAML что-то пойдёт не так, просто положи `.env` обратно.
### Откат
```bash
rm ~/.config/redmine-reporter/config.yml
```
## `.env` (legacy)
Для обратной совместимости `.env` продолжает работать без изменений.
```ini
REDMINE_URL=https://red.eltex.loc
REDMINE_API_KEY=your-api-key
REDMINE_AUTHOR=Кокос А.А.
DEFAULT_FROM_DATE=2026-06-01
DEFAULT_TO_DATE=2026-06-30
```
Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц
как период, стандартный путь сертификатов, пустой автор).
## Безопасность
- YAML-конфиг: права `0600`, директория `0700`.
- Права шире `0600` → warning в stderr при каждом запуске.
- Секреты рекомендуется хранить через `${VAR}`, а не plaintext.
- `.env` **не рекомендуется** для постоянных настроек — оставьте его только
для CI/CD или временных переопределений.

View File

@@ -4,25 +4,27 @@ build-backend = "setuptools.build_meta"
[project]
name = "redmine-reporter"
version = "0.1.1"
version = "1.9.0"
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.9"
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",
]
[project.optional-dependencies]
@@ -41,10 +43,20 @@ redmine-reporter = "redmine_reporter.cli:main"
where = ["."]
include = ["redmine_reporter*"]
[tool.setuptools.package-data]
"redmine_reporter" = ["templates/template.odt"]
[tool.black]
line-length = 100
target-version = ['py38']
target-version = ['py39']
[tool.isort]
profile = "black"
multi_line_output = 3
[tool.mypy]
warn_unused_configs = true
[[tool.mypy.overrides]]
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"]
ignore_missing_imports = true

View File

@@ -1 +1 @@
__version__ = "0.1.1"
__version__ = "1.9.0"

View File

@@ -1,73 +1,448 @@
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 . 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 .report_builder import build_grouped_report, calculate_summary
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
def parse_date_range(date_arg: str) -> tuple[str, str]:
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}"
if not re.fullmatch(date_pattern, from_date) or not re.fullmatch(date_pattern, to_date):
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try:
start = datetime.strptime(from_date, "%Y-%m-%d").date()
end = datetime.strptime(to_date, "%Y-%m-%d").date()
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
if start > end:
raise ValueError("Date range start must be less than or equal to end")
return start.isoformat(), end.isoformat()
def _run_init_config(config_path: str, force: bool) -> int:
"""Создаёт YAML-конфиг из текущих настроек окружения."""
path = Path(config_path)
if path.exists() and not force:
print(
f"⚠️ {path} already exists.\n" f" Use --init-config --force to overwrite.",
file=sys.stderr,
)
return 1
# Собираем значения из окружения
data = {
"redmine": {
"url": os.getenv("REDMINE_URL", "").strip().rstrip("/"),
"api_key": ("${REDMINE_API_KEY}" if os.getenv("REDMINE_API_KEY") else ""),
"author": os.getenv("REDMINE_AUTHOR", "").strip(),
"verify_ssl": True,
},
"period": {
"precision": "date",
"default_from": os.getenv("DEFAULT_FROM_DATE", "").strip(),
"default_to": os.getenv("DEFAULT_TO_DATE", "").strip(),
"dynamic": False,
},
"output": {
"dir": "",
"filename": "{author}_{from}_{to}.{ext}",
"default_format": "xlsx",
},
"email": {
"smtp": {
"host": "",
"port": 587,
"user": "",
"password": ("${SMTP_PASSWORD}" if os.getenv("SMTP_PASSWORD") else ""),
"tls": True,
},
"from": "",
"to": [],
"cc": [],
"bcc": [],
"subject": "Отчёт {author} за {period}",
"body_text": "Во вложении отчёт.",
"attach": True,
},
}
ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh:
yaml.dump(data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False)
path.chmod(0o600)
sections_found = [s for s in data if data[s]]
print(f"✅ Config written to {path}")
print(f" Sections: {', '.join(sections_found)}")
print(" Secrets stored as ${VAR} references where detected.")
return 0
def _compute_dedup_cutoff() -> Optional[datetime]:
"""Compute deduplication cutoff from config.
If period.precision == 'datetime' and period.last_used.to is set,
return the datetime to filter out entries already accounted for.
Otherwise return None.
"""
if Config.get_period_precision() != "datetime":
return None
last_to = Config.get_last_used_to()
if not last_to:
return None
try:
dt = datetime.fromisoformat(last_to.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except (ValueError, TypeError):
return None
def main(argv: Optional[List[str]] = None) -> int:
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",
)
args = parser.parse_args(argv)
# --init-config: обработка до всего остального
if args.init_config:
# Проверка на взаимоисключающие флаги
report_flags = [
args.date is not None,
args.output,
args.compact,
args.summary,
args.user_id,
args.user_login,
args.user_name,
args.no_time,
args.by_activity,
]
if any(report_flags):
print(
"❌ --init-config cannot be used with report-generating flags.",
file=sys.stderr,
)
return 1
return _run_init_config(args.config_path, args.force)
# Валидация взаимоисключающих флагов пользователя
user_args = [args.user_id, args.user_login, args.user_name]
if sum(bool(a) for a in user_args) > 1:
print(
"❌ Specify only one of --user-id, --user-login, or --user-name.",
file=sys.stderr,
)
return 1
# CLI-переопределения имеют приоритет над .env/env.
if args.config:
Config.load_config(args.config)
Config.set_redmine_url(args.url)
Config.set_redmine_api_key(args.api_key)
# Автозагрузка YAML-конфига
yaml_path = args.config_path
Config.load_yaml(yaml_path)
# Настройка уровня логирования
if args.debug:
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
elif args.verbose:
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
else:
logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
try:
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"{e.message}", file=sys.stderr)
if args.debug and e.original is not None:
logging.exception("Original Redmine API error")
return 1
except Exception as e:
print(f"Redmine API error: {e}", file=sys.stderr)
print(f"Unexpected error: {e}", file=sys.stderr)
return 1
if issue_hours is None:
print(" No time entries found in the given period.", file=sys.stderr)
return 0
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]")
if not issue_hours:
print(" No time entries found in the given period.", file=sys.stderr)
return 0
print(f"✅ Total issues: {len(issue_hours)} [{date_arg}]", file=sys.stderr)
rows = build_grouped_report(
issue_hours,
fill_time=not args.no_time,
by_activity=args.by_activity,
)
if args.summary:
summary = calculate_summary(rows, by_activity=args.by_activity)
print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr)
project_keys = [k for k in sorted(summary) if k.startswith("project:")]
activity_keys = [k for k in sorted(summary) if k.startswith("activity:")]
for key in project_keys + activity_keys:
value = summary[key]
if key.startswith("project:"):
project = key.split(":", 1)[1]
print(f" {project}: {value}h", file=sys.stderr)
elif key.startswith("activity:"):
activity = key.split(":", 1)[1]
print(f" [{activity}]: {value}h", file=sys.stderr)
if args.output:
output_arg = resolve_output_path(
args.output,
output_dir=Config.get_output_dir(),
filename_template=Config.get_output_filename(),
default_format=Config.get_output_default_format(),
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
)
if output_arg is None:
print(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr,
)
return 1
output_ext = os.path.splitext(output_arg)[1].lower()
if not output_ext:
print(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr,
)
return 1
formatter = get_formatter_by_extension(
output_ext,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
no_time=args.no_time,
)
if not formatter:
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:
if args.compact:
output = format_compact(issue_hours)
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
elif args.commit:
default_format = Config.get_output_default_format()
output_arg = resolve_output_path(
default_format,
output_dir=Config.get_output_dir(),
filename_template=Config.get_output_filename(),
default_format=default_format,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
)
if output_arg is None:
print("Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
return 1
output_ext = os.path.splitext(output_arg)[1].lower()
formatter = get_formatter_by_extension(
output_ext,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
no_time=args.no_time,
)
if not formatter:
print(f"Не удалось создать форматтер для {output_ext}", file=sys.stderr)
return 1
try:
formatter.save(rows, output_arg)
print(f"✅ Report saved to {output_arg}")
except Exception as e:
print(f"❌ Export error: {e}", file=sys.stderr)
return 1
else:
output = format_table(issue_hours)
if args.compact:
formatter = get_console_formatter("compact")
else:
formatter = get_console_formatter("table")
if not formatter:
print("❌ Неизвестный тип консольного форматтера.", file=sys.stderr)
return 1
try:
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":
from datetime import datetime as dt_mod
now = dt_mod.now().isoformat(timespec="seconds")
from_str = now
to_str = now
else:
from_str = from_date
to_str = to_date
save_period_to_config(args.config_path, from_str, to_str, precision, dynamic)
print(
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
file=sys.stderr,
)
return 0

View File

@@ -1,58 +1,330 @@
from typing import List, Optional, Dict, Tuple
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple, Union
import requests
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]]]:
"""
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.
"""
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)."""
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": Config.get_redmine_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:
return {}
def _get_activity_name(entry, activities: Dict[int, str]) -> str:
"""Определяет название активности для time entry."""
activity = getattr(entry, "activity", None)
if activity is None:
return "<N/A>"
# activity может быть объектом с id/name или просто значением
activity_id = getattr(activity, "id", None)
if activity_id is not None:
name = activities.get(int(activity_id))
if name:
return name
activity_name = getattr(activity, "name", None)
if activity_name:
return str(activity_name)
return str(activity_id)
return str(activity)
def _parse_datetime(value: Any) -> Optional[datetime]:
"""Parse a datetime from Redmine API response.
Accepts datetime objects, ISO strings (with or without timezone),
or None. Returns a timezone-aware datetime or None.
"""
if value is None:
return None
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return dt
except (ValueError, TypeError):
return None
return None
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = []
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
issue_list_str = ",".join(str(x) for x in chunk)
issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc")
all_issues.extend(issues)
return all_issues
def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int:
"""Преобразует строковый идентификатор пользователя в числовой ID.
Если аргумент — число, возвращает его как есть.
Если строка, пытается найти пользователя по логину или имени.
"""
if isinstance(user_arg, int):
return user_arg
text = str(user_arg).strip()
if not text:
raise RedmineAPIError("User identifier cannot be empty.")
# Сначала пробуем интерпретировать как числовой ID
if text.isdigit():
return int(text)
# Затем ищем по логину
try:
users = redmine.user.filter(login=text)
if users:
return int(users[0].id)
except Exception as exc:
raise RedmineAPIError(
f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}",
original=exc,
) from exc
# Потом по имени
try:
users = redmine.user.filter(name=text)
if len(users) == 1:
return int(users[0].id)
if len(users) > 1:
matches = ", ".join(str(getattr(u, "login", u.id)) for u in users[:5])
raise RedmineAPIError(
f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID."
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(
f"Cannot resolve user name '{text}': {_format_redmine_error(exc)}",
original=exc,
) from exc
raise RedmineAPIError(
f"User '{text}' not found. Check the login/name or use --user-id with numeric Redmine ID."
)
# Агрегируем часы по issue.id
def _get_current_user_id(redmine: Redmine) -> int:
"""Возвращает ID текущего пользователя."""
try:
current_user = redmine.user.get("current")
return int(current_user.id)
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
def fetch_issues_with_spent_time(
from_date: str,
to_date: str,
user_id: Optional[Union[int, str]] = None,
by_activity: bool = False,
dedup_before: Optional[datetime] = None,
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]:
"""
Fetch unique issues linked to time entries of the given user in date range,
along with total spent hours per issue.
If user_id is None, uses current user.
If by_activity is True, returns per-activity breakdown as third tuple element.
If dedup_before is set, filters out time entries whose created_on AND updated_on
are both before dedup_before (AND logic: both must be < cutoff to exclude).
Returns list of (issue, total_hours, activities) tuples.
Raises RedmineAPIError on API/auth/network failures.
"""
try:
redmine = _create_redmine()
target_user_id = (
_resolve_user_id(redmine, user_id)
if user_id is not None
else _get_current_user_id(redmine)
)
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
time_entries = list(
redmine.time_entry.filter(user_id=target_user_id, from_date=from_date, to_date=to_date)
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте.
# Запись исключается, если BOTH created_on AND updated_on < dedup_before.
# Записи без метаданных (created_on/updated_on == None) не фильтруются.
if dedup_before is not None:
filtered: list = []
for entry in time_entries:
created = _parse_datetime(getattr(entry, "created_on", None))
updated = _parse_datetime(getattr(entry, "updated_on", None))
if created is None and updated is None:
filtered.append(entry)
elif created is not None and updated is not None:
if created >= dedup_before and updated >= dedup_before:
filtered.append(entry)
elif created is not None and created >= dedup_before:
filtered.append(entry)
elif updated is not None and updated >= dedup_before:
filtered.append(entry)
time_entries = filtered
# Агрегируем часы по issue.id (и активности, если требуется)
spent_time: Dict[int, float] = {}
spent_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
# Сопоставляем задачи с суммарным временем
# Сопоставляем задачи с суммарным временем.
# Сортировка выполняется в 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

View File

@@ -1,29 +1,434 @@
import logging
import os
from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path
from typing import Union
import yaml
from dotenv import load_dotenv
from .yaml_config import check_file_permissions, resolve_env_vars
load_dotenv()
load_dotenv(override=False)
logger = logging.getLogger(__name__)
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
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
@dataclass
class PeriodLastUsed:
from_: str = ""
to: str = ""
@dataclass
class AppConfig:
redmine_url: str = ""
redmine_api_key: str = ""
redmine_author: str = ""
redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY
period_precision: str = "date"
period_default_from: str = ""
period_default_to: str = ""
period_dynamic: bool = False
period_last_used_from: str = ""
period_last_used_to: str = ""
output_dir: str = ""
output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx"
email: EmailConfig = field(default_factory=EmailConfig)
@classmethod
def from_yaml(cls, path: Union[str, Path]) -> "AppConfig":
"""Загружает настройки из YAML-файла.
Если файл не существует — возвращает конфиг со значениями по умолчанию.
Неизвестные ключи верхнего уровня логируются с warning.
"""
path = Path(path)
if not path.exists():
return cls()
# Проверяем права файла
for warning in check_file_permissions(path):
logger.warning(warning)
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
# Предупреждаем о неизвестных ключах верхнего уровня
known_keys = {
"redmine",
"period",
"output",
"email",
}
for key in raw:
if key not in known_keys:
logger.warning("Unknown top-level key in config: %s", key)
return cls(
redmine_url=cls._resolve_str(raw, "redmine", "url"),
redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"),
redmine_author=cls._resolve_str(raw, "redmine", "author"),
redmine_verify=cls._resolve_verify(raw),
period_precision=cls._resolve_str(raw, "period", "precision") or "date",
period_default_from=cls._resolve_str(raw, "period", "default_from"),
period_default_to=cls._resolve_str(raw, "period", "default_to"),
period_dynamic=cls._resolve_bool(raw, "period", "dynamic"),
period_last_used_from=cls._resolve_str(raw, "period", "last_used", "from"),
period_last_used_to=cls._resolve_str(raw, "period", "last_used", "to"),
output_dir=cls._resolve_str(raw, "output", "dir"),
output_filename=cls._resolve_str(raw, "output", "filename")
or "{author}_{from}_{to}.{ext}",
output_default_format=cls._resolve_str(raw, "output", "default_format") or "xlsx",
email=cls._resolve_email(raw),
)
@classmethod
def from_env(cls) -> "AppConfig":
"""Загружает настройки из переменных окружения."""
return cls(
redmine_url=os.getenv("REDMINE_URL", "").strip().rstrip("/"),
redmine_api_key=os.getenv("REDMINE_API_KEY", "").strip(),
redmine_author=os.getenv("REDMINE_AUTHOR", "").strip(),
period_default_from=os.getenv("DEFAULT_FROM_DATE", "").strip(),
period_default_to=os.getenv("DEFAULT_TO_DATE", "").strip(),
)
@staticmethod
def _resolve_str(raw: dict, section: str, *keys: str) -> str:
value = raw.get(section)
for key in keys:
if isinstance(value, dict):
value = value.get(key)
else:
value = None
break
if isinstance(value, str):
return resolve_env_vars(value)
if value is None:
return ""
return str(value)
@staticmethod
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
value = raw.get(section, {}).get(key)
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.lower()
if normalized in TRUE_VALUES:
return True
if normalized in FALSE_VALUES:
return False
return False
@classmethod
def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
value = raw.get("redmine", {}).get("verify_ssl")
if value is None:
return DEFAULT_REDMINE_VERIFY
if isinstance(value, bool):
return DEFAULT_REDMINE_VERIFY if value else False
if isinstance(value, str):
normalized = value.lower()
if normalized in FALSE_VALUES:
return False
if normalized in TRUE_VALUES:
return DEFAULT_REDMINE_VERIFY
return resolve_env_vars(value)
return DEFAULT_REDMINE_VERIFY
@classmethod
def _resolve_email(cls, raw: dict) -> EmailConfig:
email_raw = raw.get("email", {}) or {}
smtp_raw = email_raw.get("smtp", {}) or {}
return EmailConfig(
smtp=SmtpConfig(
host=cls._safe_str(smtp_raw.get("host")),
port=cls._safe_int(smtp_raw.get("port"), 587),
user=cls._safe_str(smtp_raw.get("user")),
password=resolve_env_vars(cls._safe_str(smtp_raw.get("password"))),
tls=cls._safe_bool(smtp_raw.get("tls"), True),
),
from_=cls._safe_str(email_raw.get("from")),
to=cls._safe_list(email_raw.get("to")),
cc=cls._safe_list(email_raw.get("cc")),
bcc=cls._safe_list(email_raw.get("bcc")),
subject=resolve_env_vars(
cls._safe_str(email_raw.get("subject")) or "Отчёт {author} за {period}"
),
body_text=resolve_env_vars(
cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт."
),
attach=cls._safe_bool(email_raw.get("attach"), True),
)
@staticmethod
def _safe_str(value) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
return str(value)
@staticmethod
def _safe_int(value, default: int = 0) -> int:
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
@staticmethod
def _safe_bool(value, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.lower()
if normalized in TRUE_VALUES:
return True
if normalized in FALSE_VALUES:
return False
return default
@staticmethod
def _safe_list(value) -> list:
if value is None:
return []
if isinstance(value, list):
return [str(v) for v in value]
return [str(value)]
class Config:
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:
"""Загружает YAML-конфиг. Не бросает исключений при отсутствии файла."""
cls._app = AppConfig.from_yaml(path)
@classmethod
def load_config(cls, path: str) -> None:
"""Загружает переменные из указанного .env-файла с override."""
load_dotenv(path, override=True)
@classmethod
def set_redmine_url(cls, url: str) -> None:
cls._cli_url = url.strip().rstrip("/") if url else None
@classmethod
def set_redmine_api_key(cls, key: str) -> None:
cls._cli_api_key = key.strip() if key else None
@classmethod
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_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()
if from_env and to_env:
return f"{from_env}--{to_env}"
if (
cls._app
and cls._app.period_dynamic
and cls._app.period_last_used_from
and cls._app.period_last_used_to
):
nf, nt = compute_next_period(
cls._app.period_last_used_from,
cls._app.period_last_used_to,
cls._app.period_precision or "date",
)
return f"{nf}--{nt}"
if cls._app and cls._app.period_default_from and cls._app.period_default_to:
return f"{cls._app.period_default_from}--{cls._app.period_default_to}"
today = date.today()
start = today.replace(day=1)
if today.month == 12:
next_month = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month = today.replace(month=today.month + 1, day=1)
end = next_month - timedelta(days=1)
return f"{start.isoformat()}--{end.isoformat()}"
@classmethod
def validate(cls) -> None:
if not cls.REDMINE_URL:
if not cls.get_redmine_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 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()

View File

@@ -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 ""
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 ""
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")

View File

View 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

View 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 не поддерживает сохранение в файл.")

View 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)

View 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

View 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)

View 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)

View 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)

View File

@@ -0,0 +1,168 @@
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)

View File

@@ -0,0 +1,214 @@
from typing import Dict, List, Tuple
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.worksheet import Worksheet
from ..report_builder import group_rows_by_project_and_version
from ..types import ReportRow
from ..utils import hours_to_human
from .base import Formatter
class XLSXFormatter(Formatter):
"""Форматтер для экспорта отчёта в Excel (.xlsx).
Использует группировку по проекту и версии: объединяет ячейки,
добавляет итоги по группам, закрепляет заголовок, включает автофильтр
и числовой столбец с часами для удобного суммирования.
"""
_HEADER_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
_TOTAL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
_BORDER = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
def __init__(self, no_time: bool = False, **_kwargs):
super().__init__()
self.no_time = no_time
def format(self, rows: List[ReportRow]) -> Workbook:
wb = Workbook()
ws = wb.active
if ws is None:
ws = wb.create_sheet("Report")
else:
ws.title = "Report"
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Hours", "Spent Time"]
ws.append(headers)
self._style_header_row(ws, headers)
grouped = group_rows_by_project_and_version(rows)
current_row = 2
project_ranges: List[Tuple[int, int]] = []
version_ranges: List[Tuple[int, int]] = []
project_totals: Dict[str, float] = {}
for project, versions in grouped.items():
project_start_row = current_row
for version, task_rows in versions.items():
version_start_row = current_row
for r in task_rows:
hours = "" if self.no_time else r.get("hours", 0.0)
time_text = "" if self.no_time else r["time_text"]
ws.append(
[
project,
version,
r["issue_id"],
r["subject"],
r["status_ru"],
hours,
time_text,
]
)
self._style_data_row(ws, current_row)
current_row += 1
if not self.no_time:
version_hours = sum(r.get("hours", 0.0) for r in task_rows)
ws.append(
[
"",
f"Total {version}",
"",
"",
"",
version_hours,
hours_to_human(version_hours),
]
)
self._style_total_row(ws, current_row, bold=False)
ws.merge_cells(
start_row=current_row, start_column=2, end_row=current_row, end_column=5
)
current_row += 1
version_end_row = current_row - 1
if version_end_row > version_start_row:
version_ranges.append((version_start_row, version_end_row))
if not self.no_time:
project_hours = sum(
sum(r.get("hours", 0.0) for r in task_rows) for task_rows in versions.values()
)
ws.append(
[
f"Total {project}",
"",
"",
"",
"",
project_hours,
hours_to_human(project_hours),
]
)
self._style_total_row(ws, current_row, bold=True)
ws.merge_cells(
start_row=current_row, start_column=1, end_row=current_row, end_column=5
)
current_row += 1
project_totals[project] = project_hours
project_end_row = current_row - 1
if project_end_row > project_start_row:
project_ranges.append((project_start_row, project_end_row))
if not self.no_time and project_totals:
total_hours = sum(project_totals.values())
ws.append(
[
"Total",
"",
"",
"",
"",
total_hours,
hours_to_human(total_hours),
]
)
self._style_total_row(ws, current_row, bold=True)
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=5)
for start, end in project_ranges:
ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1)
cell = ws.cell(row=start, column=1)
cell.alignment = Alignment(vertical="top", wrap_text=True)
for start, end in version_ranges:
ws.merge_cells(start_row=start, start_column=2, end_row=end, end_column=2)
cell = ws.cell(row=start, column=2)
cell.alignment = Alignment(vertical="top", wrap_text=True)
self._apply_column_widths(ws)
if not self.no_time:
self._apply_number_format(ws)
self._apply_auto_filter(ws, ws.max_row)
ws.freeze_panes = "A2"
return wb
def save(self, rows: List[ReportRow], output_path: str) -> None:
wb = self.format(rows)
wb.save(output_path)
def _style_header_row(self, ws: Worksheet, headers: List[str]) -> None:
for col_idx, _ in enumerate(headers, start=1):
cell = ws.cell(row=1, column=col_idx)
cell.font = Font(bold=True)
cell.fill = self._HEADER_FILL
cell.border = self._BORDER
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
def _style_data_row(self, ws: Worksheet, row: int) -> None:
for col_idx in range(1, 8):
cell = ws.cell(row=row, column=col_idx)
cell.border = self._BORDER
if col_idx in (1, 2):
cell.alignment = Alignment(vertical="top", wrap_text=True)
elif col_idx == 4:
cell.alignment = Alignment(vertical="top", wrap_text=True)
else:
cell.alignment = Alignment(vertical="top")
def _style_total_row(self, ws: Worksheet, row: int, bold: bool) -> None:
for col_idx in range(1, 8):
cell = ws.cell(row=row, column=col_idx)
cell.border = self._BORDER
cell.fill = self._TOTAL_FILL
cell.font = Font(bold=bold)
cell.alignment = Alignment(vertical="center")
def _apply_column_widths(self, ws: Worksheet) -> None:
# Минимальные ширины по умолчанию
widths: Dict[int, float] = {1: 18.0, 2: 16.0, 3: 12.0, 4: 45.0, 5: 14.0, 6: 10.0, 7: 14.0}
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
for col_idx, cell in enumerate(row, start=1):
if cell.value is None:
continue
text = str(cell.value)
# Оценочная ширина: примерно 1.1 символа на единицу ширины Excel
estimated = len(text) * 1.1 + 2
widths[col_idx] = max(widths[col_idx], min(estimated, 80))
for col_idx, width in widths.items():
ws.column_dimensions[get_column_letter(col_idx)].width = width
def _apply_number_format(self, ws: Worksheet) -> None:
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=6, max_col=6):
for cell in row:
if isinstance(cell.value, (int, float)):
cell.number_format = "0.00"
def _apply_auto_filter(self, ws: Worksheet, max_row: int) -> None:
ws.auto_filter.ref = f"A1:G{max_row}"

View File

@@ -0,0 +1,152 @@
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,
) -> List[ReportRow]:
"""
Преобразует список задач с затраченным временем в плоский список строк отчёта,
с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений).
Предусловие: issue_hours должен быть отсортирован по (project, version).
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
"""
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id))
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 = STATUS_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

Binary file not shown.

21
redmine_reporter/types.py Normal file
View 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]]

View File

@@ -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 ""
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 ""

View File

@@ -0,0 +1,186 @@
import logging
import os
import re
from pathlib import Path
logger = logging.getLogger(__name__)
_ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}")
def resolve_env_vars(value: str) -> str:
"""Replace ${VAR} patterns with environment variable values.
Non-recursive: if ${A} expands to a literal ${B}, ${B} is NOT resolved.
Unknown variables are replaced with empty string and a warning is logged.
"""
if not isinstance(value, str):
return value
def _replacer(match: re.Match) -> str:
var_name = match.group(1)
env_value = os.environ.get(var_name)
if env_value is None:
logger.warning("Environment variable %s is not set, using empty string", var_name)
return ""
return env_value
return _ENV_VAR_RE.sub(_replacer, value)
def ensure_config_dir(path: Path | None = None) -> Path:
"""Create ~/.config/redmine-reporter/ with 0o700 permissions.
Returns the path to the config directory.
No-op if the directory already exists.
"""
if path is None:
path = Path.home() / ".config" / "redmine-reporter"
path.mkdir(mode=0o700, parents=True, exist_ok=True)
return path
def check_file_permissions(path: Path) -> list[str]:
"""Check that a config file has safe permissions (0o600 or stricter).
Returns a list of warning messages. Empty list means no issues.
"""
warnings: list[str] = []
if not path.exists():
return warnings
mode = path.stat().st_mode & 0o777
if mode > 0o600:
warnings.append(
f"Config file permissions are too open ({mode:04o}). "
f"Expected 0600. Fix with: chmod 600 {path}"
)
return warnings
def expand_filename_template(
template: str,
*,
author: str = "",
from_date: str = "",
to_date: str = "",
ext: str = "",
) -> str:
"""Expand placeholders in a filename template.
Supported placeholders:
{author} — author name
{from} — start date (YYYY-MM-DD)
{to} — end date (YYYY-MM-DD)
{date} — end date formatted as DD_MM_YYYY
{ext} — file extension without dot
Unknown placeholders are left as-is.
"""
date_dd_mm_yyyy = ""
if to_date:
try:
from datetime import datetime
dt = datetime.strptime(to_date, "%Y-%m-%d")
date_dd_mm_yyyy = dt.strftime("%d_%m_%Y")
except ValueError:
date_dd_mm_yyyy = to_date
replacements = {
"author": author.replace(" ", "_"),
"from": from_date,
"to": to_date,
"date": date_dd_mm_yyyy,
"ext": ext,
}
result = template
for key, value in replacements.items():
result = result.replace("{" + key + "}", value)
return result
KNOWN_FORMAT_NAMES = {"xlsx", "odt", "csv", "md", "html", "json"}
def resolve_output_path(
output_arg: str | None,
*,
output_dir: str = "",
filename_template: str = "{author}_{from}_{to}.{ext}",
default_format: str = "xlsx",
author: str = "",
from_date: str = "",
to_date: str = "",
) -> str | None:
"""Resolve the final output path from --output argument and config settings.
Returns None if output_arg is None (meaning no file output, use console).
Resolution rules:
- None → None (console output)
- Bare format name (xlsx, odt, csv, md, html, json) → use template path
- Path without extension → append .{default_format}
- Path with known extension → use as-is
- Path with unknown extension → use as-is
"""
if output_arg is None:
return None
arg = output_arg.strip()
import os
if arg.lower() in KNOWN_FORMAT_NAMES:
ext = arg.lower()
resolved_dir = os.path.expanduser(output_dir) if output_dir else "."
filename = expand_filename_template(
filename_template,
author=author,
from_date=from_date,
to_date=to_date,
ext=ext,
)
return os.path.join(resolved_dir, filename)
_, ext = os.path.splitext(arg)
if not ext:
arg = f"{arg}.{default_format}"
return arg
def save_period_to_config(
config_path: str,
from_str: str,
to_str: str,
precision: str,
dynamic: bool,
) -> None:
"""Save committed period to YAML config file.
Writes period.last_used.from / period.last_used.to.
When dynamic=False, also overwrites period.default_from / period.default_to.
Preserves all other sections unchanged. Creates config file if missing.
"""
import yaml
path = Path(config_path)
raw: dict = {}
if path.exists():
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
period = raw.setdefault("period", {})
period["last_used"] = {"from": from_str, "to": to_str}
if not dynamic:
period["default_from"] = from_str.split("T")[0] if "T" in from_str else from_str
period["default_to"] = to_str.split("T")[0] if "T" in to_str else to_str
ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh:
yaml.dump(raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False)
path.chmod(0o600)

View File

@@ -1,20 +1,767 @@
import os
from unittest import mock
import pytest
from redmine_reporter.cli import parse_date_range
from redmine_reporter.cli import main, parse_date_range
VALID_ENV = {
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_API_KEY": "token",
}
def test_parse_date_range_valid():
assert parse_date_range("2025-01-01--2025-12-31") == ("2025-01-01", "2025-12-31")
@pytest.fixture(autouse=True)
def _reset_config_overrides():
"""Сбрасывает CLI-переопределения Config перед каждым тестом."""
from redmine_reporter.config import Config
Config.set_redmine_url("")
Config.set_redmine_api_key("")
Config._app = None
yield
Config.set_redmine_url("")
Config.set_redmine_api_key("")
Config._app = None
def test_parse_date_range_with_spaces():
assert parse_date_range("2025-01-01 -- 2025-12-31") == ("2025-01-01", "2025-12-31")
@pytest.mark.parametrize(
"date_arg, expected",
[
("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")),
(" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")),
],
)
def test_parse_date_range_valid(date_arg, expected):
assert parse_date_range(date_arg) == expected
def test_parse_date_range_invalid_no_separator():
with pytest.raises(ValueError, match="must be in format"):
parse_date_range("2025-01-01")
@pytest.mark.parametrize(
"date_arg",
[
"20260101-20260131",
"2026-1-01--2026-01-31",
"2026-02-30--2026-03-01",
"2026-02-01--2026-01-31",
],
)
def test_parse_date_range_invalid(date_arg):
with pytest.raises(ValueError):
parse_date_range(date_arg)
def test_parse_date_range_invalid_parts():
with pytest.raises(ValueError, match="Invalid date range format"):
parse_date_range("2025-01-01--")
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_returns_zero_on_no_entries(mock_fetch):
"""None от fetch (нет time entries) -- выход 0."""
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31"])
assert code == 0
@mock.patch.dict(os.environ, {}, clear=True)
def test_cli_config_error():
"""Невалидный конфиг -- выход 1."""
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", "/nonexistent/config.yml"])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_invalid_date_format():
"""Неверный формат даты -- выход 1."""
code = main(["--date", "20260101-20260131"])
assert code == 1
class _MockIssue:
"""Простой mock Redmine Issue для CLI-тестов."""
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
self.id = issue_id
self.subject = subject
self.project = project
self.status = status
self.fixed_version = None
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
"""Неизвестное расширение файла -- выход 1."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xyz")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
"""Файл без расширения — приложение расширяет default_format (#43)."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
output = str(tmp_path / "report")
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--output",
output,
"--config-path",
str(tmp_path / "nonexistent.yml"),
]
)
assert code == 0
saved_path = mock_formatter.save.call_args.args[1]
assert saved_path.endswith(".xlsx")
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
"""При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_gf.return_value = None
output = str(tmp_path / "report.odt")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1
captured = capsys.readouterr()
assert "odfpy" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_version_flag(capsys):
"""--version выводит версию и завершается с 0."""
with pytest.raises(SystemExit) as exc_info:
main(["--version"])
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "redmine-reporter" in captured.out
from redmine_reporter import __version__
assert __version__ in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_verbose_and_debug_flags_accepted(mock_fetch):
"""--verbose и --debug не ломают запуск."""
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31", "--verbose"])
assert code == 0
code = main(["--date", "2026-01-01--2026-01-31", "--debug"])
assert code == 0
class _MockIssue:
"""Простой mock Redmine Issue для CLI-тестов."""
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
self.id = issue_id
self.subject = subject
self.project = project
self.status = status
self.fixed_version = None
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_url_and_api_key_override_env(mock_fetch):
"""--url и --api-key переопределяют отсутствующие/другие env-переменные."""
mock_fetch.return_value = None
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--url",
"https://other.redmine.loc",
"--api-key",
"cli-token",
]
)
assert code == 0
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_config_file_loading(mock_fetch, tmp_path):
"""--config загружает переменные из указанного .env-файла."""
config_path = tmp_path / "custom.env"
config_path.write_text(
"REDMINE_URL=https://config.redmine.loc\n" "REDMINE_API_KEY=config-token\n",
encoding="utf-8",
)
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31", "--config", str(config_path)])
assert code == 0
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
"""--summary выводит общее время и разбивку по проектам в stderr."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
main(["--date", "2026-01-01--2026-01-31", "--summary"])
captured = capsys.readouterr()
assert "Total time" in captured.err
assert "Project" in captured.err or "Project" in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert "Total issues" not in captured.out
assert "Total issues" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_auth_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при ошибке аутентификации."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "Authentication failed" in captured.err
assert "Redmine API error" not in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_forbidden_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при недостаточных правах."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Access denied: no permission")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "Access denied" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при таймауте."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Redmine request timed out after 30 seconds")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "timed out" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
"""CLI --no-time передаётся в файловый форматтер."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xlsx")
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get_formatter:
mock_formatter = mock.MagicMock()
mock_get_formatter.return_value = mock_formatter
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
_, kwargs = mock_get_formatter.call_args
assert kwargs.get("no_time") is True
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_passes_user_id_to_fetch(mock_fetch):
"""CLI --user-id передаётся в fetch_issues_with_spent_time."""
mock_fetch.return_value = None
main(["--date", "2026-01-01--2026-01-31", "--user-id", "42"])
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "42"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_passes_user_login_to_fetch(mock_fetch):
"""CLI --user-login передаётся в fetch_issues_with_spent_time."""
mock_fetch.return_value = None
main(["--date", "2026-01-01--2026-01-31", "--user-login", "ivanov"])
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "ivanov"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_rejects_multiple_user_flags():
"""CLI не принимает одновременно несколько флагов пользователя."""
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--user-id",
"42",
"--user-login",
"ivanov",
]
)
assert code == 1
# ---------------------------------------------------------------------------
# --init-config tests
# ---------------------------------------------------------------------------
class TestInitConfig:
"""Tests for --init-config flag."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_creates_yaml_file(self, tmp_path):
"""--init-config создаёт YAML файл с правильной структурой."""
import yaml
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
assert config_path.exists()
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert "redmine" in data
assert data["redmine"]["url"] == "https://red.eltex.loc"
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_existing_file_no_force(self, tmp_path, capsys):
"""Без --force существующий файл → предупреждение и выход 1."""
config_path = tmp_path / "config.yml"
config_path.write_text("existing: true")
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 1
captured = capsys.readouterr()
assert "already exists" in captured.err or "already exists" in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_force_overwrites(self, tmp_path):
"""--init-config --force перезаписывает существующий файл."""
import yaml
config_path = tmp_path / "config.yml"
config_path.write_text("existing: true")
code = main(["--init-config", "--force", "--config-path", str(config_path)])
assert code == 0
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert "redmine" in data
assert "existing" not in data
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_with_date_is_error(self, capsys):
"""--init-config с --date → ошибка, взаимоисключающие."""
code = main(["--init-config", "--date", "2026-01-01--2026-01-31"])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_writes_secrets_as_env_ref(self, tmp_path):
"""Секреты пишутся как ${VAR} когда переменная окружения существует."""
import yaml
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
@mock.patch.dict(os.environ, {}, clear=True)
def test_init_config_no_env_vars(self, tmp_path):
"""Без переменных окружения --init-config создаёт скелет."""
import yaml
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert "redmine" in data
assert data["redmine"]["url"] == ""
assert data["redmine"]["api_key"] == ""
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_autoloads_yaml_config(mock_fetch, tmp_path):
"""CLI автоматически подгружает YAML-конфиг при запуске."""
config_path = tmp_path / "config.yml"
config_path.write_text(
"redmine:\n url: https://yaml-redmine.example.com/\n api_key: yaml-token\n"
)
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)])
assert code == 0
# ---------------------------------------------------------------------------
# #43: --output path resolution tests
# ---------------------------------------------------------------------------
class TestOutputPathResolution:
"""Tests for --output flag with path defaults."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_bare_format_name_xlsx_uses_template_path(self, mock_fetch, tmp_path):
"""--output xlsx без пути → использует шаблон из конфига."""
import yaml
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump(
{
"output": {
"dir": str(tmp_path / "reports"),
"filename": "report_{date}.{ext}",
"default_format": "xlsx",
}
}
)
)
mock_fetch.return_value = None
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--output",
"xlsx",
"--config-path",
str(config_path),
]
)
assert code == 0
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_output_without_extension_appends_default_format(self, mock_fetch, tmp_path):
"""--output report без расширения → добавляет .xlsx."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report")
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--output",
output,
"--config-path",
str(tmp_path / "nonexistent.yml"),
]
)
assert code == 0
mock_formatter.save.assert_called_once()
saved_path = mock_formatter.save.call_args.args[1]
assert saved_path.endswith(".xlsx")
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path):
"""--output report.csv с явным расширением передаётся как есть."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--output",
str(tmp_path / "report.csv"),
"--config-path",
str(tmp_path / "nonexistent.yml"),
]
)
assert code == 0
mock_formatter.save.assert_called_once()
saved_path = mock_formatter.save.call_args.args[1]
assert saved_path.endswith(".csv")
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_output_bare_format_invalid_is_treated_as_path(self, mock_fetch, tmp_path):
"""--output notanxlsx (no path, not known format) → treated as path + .xlsx appended."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--output",
"myreport",
"--config-path",
str(tmp_path / "nonexistent.yml"),
]
)
assert code == 0
mock_formatter.save.assert_called_once()
saved_path = mock_formatter.save.call_args.args[1]
assert saved_path == "myreport.xlsx"
# ---------------------------------------------------------------------------
# #44: --commit tests
# ---------------------------------------------------------------------------
class TestCommitFlag:
"""Tests for --commit flag."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_saves_period_to_config(self, mock_save, mock_fetch, tmp_path):
"""--commit сохраняет период в YAML-конфиг."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_with_precision_datetime_passes_datetime_strings(
self, mock_save, mock_fetch, tmp_path
):
"""При precision=datetime --commit сохраняет timestamps."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "datetime", "dynamic": True}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-30--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
call_args = mock_save.call_args
assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2]
assert "T" in saved_from
assert "T" in saved_to
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
"""Без записей --commit не пишет конфиг."""
mock_fetch.return_value = None
config_path = tmp_path / "config.yml"
config_path.write_text("period:\n precision: date\n dynamic: true\n")
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_dynamic_false_overwrites_defaults(self, mock_save, mock_fetch, tmp_path):
"""При dynamic=false --commit перезаписывает default_from/to."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": False}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path), "2026-06-01", "2026-06-30", "date", False
)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_without_output_uses_default_path(self, mock_save, mock_fetch, tmp_path):
"""--commit без --output сохраняет файл по шаблону из конфига."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump(
{
"period": {"precision": "date", "dynamic": True},
"output": {
"dir": str(tmp_path / "reports"),
"filename": "report_{date}.{ext}",
"default_format": "xlsx",
},
}
)
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_formatter.save.assert_called_once()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_prints_info_to_stderr(self, mock_save, mock_fetch, capsys, tmp_path):
"""--commit выводит сообщение о фиксации в stderr."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
captured = capsys.readouterr()
assert "commit" in captured.err.lower() or "Commit" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path):
"""Пустой список задач — --commit не сохраняет конфиг."""
import yaml
mock_fetch.return_value = []
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_not_called()

673
tests/test_client.py Normal file
View File

@@ -0,0 +1,673 @@
import os
from unittest import mock
import pytest
from redmine_reporter.client import fetch_issues_with_spent_time
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
PASSWORD_ENV = {
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_USER": "user",
"REDMINE_PASSWORD": "password",
}
def _configure_current_user(mock_redmine, user_id=1):
mock_user = mock.MagicMock()
mock_user.id = user_id
mock_redmine.user.get.return_value = mock_user
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
"""Два time entry на одну задачу -- часы суммируются."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_entry1 = mock.MagicMock()
mock_entry1.issue.id = 101
mock_entry1.hours = 2.0
mock_entry2 = mock.MagicMock()
mock_entry2.issue.id = 101
mock_entry2.hours = 1.5
mock_redmine.time_entry.filter.return_value = [mock_entry1, mock_entry2]
mock_issue = mock.MagicMock()
mock_issue.id = 101
mock_issue.project = "Проект X"
mock_issue.subject = "Тестовая задача"
mock_issue.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
assert len(result) == 1
issue, total_hours, _activities = result[0]
assert total_hours == 3.5
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_returns_none_when_no_entries(mock_redmine_class):
"""Нет time entries -- возвращается None."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_skips_entries_without_issue(mock_redmine_class):
"""Time entry без привязки к задаче игнорируется."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
# entry без issue атрибута
entry_no_issue = mock.MagicMock(spec=["hours"]) # нет .issue
entry_no_issue.hours = 1.0
mock_redmine.time_entry.filter.return_value = [entry_no_issue]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_multiple_issues(mock_redmine_class):
"""Несколько задач -- каждая с правильным суммарным временем."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
def make_entry(issue_id, hours):
e = mock.MagicMock()
e.issue.id = issue_id
e.hours = hours
return e
mock_redmine.time_entry.filter.return_value = [
make_entry(1, 1.0),
make_entry(2, 2.0),
make_entry(1, 0.5),
]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_issue2.project = "P"
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
assert len(result) == 2
hours_by_id = {issue.id: hours for issue, hours, _ in result}
assert hours_by_id[1] == 1.5
assert hours_by_id[2] == 2.0
@mock.patch.dict(
os.environ,
{
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_API_KEY": "api-token",
"REDMINE_USER": "user",
"REDMINE_PASSWORD": "password",
},
clear=True,
)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_api_key_when_present(mock_redmine_class):
"""Если задан API key, он используется вместо логина/пароля."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
_, kwargs = mock_redmine_class.call_args
assert kwargs["key"] == "api-token"
assert kwargs["requests"]["verify"] == DEFAULT_REDMINE_VERIFY
assert "username" not in kwargs
assert "password" not in kwargs
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
"""Если API key не задан, остаётся старая схема логин/пароль."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
_, kwargs = mock_redmine_class.call_args
assert kwargs["username"] == "user"
assert kwargs["password"] == "password"
assert kwargs["requests"]["verify"] == DEFAULT_REDMINE_VERIFY
assert "key" not in kwargs
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_custom_verify_path(mock_redmine_class):
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
"""AuthError превращается в RedmineAPIError с понятным сообщением."""
from redminelib.exceptions import AuthError
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = AuthError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Authentication failed"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_forbidden(mock_redmine_class):
"""ForbiddenError превращается в RedmineAPIError с понятным сообщением."""
from redminelib.exceptions import ForbiddenError
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = ForbiddenError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Access denied"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_timeout(mock_redmine_class):
"""requests Timeout превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = requests.exceptions.Timeout("timeout")
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="timed out"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_connection_error(mock_redmine_class):
"""ConnectionError превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = requests.exceptions.ConnectionError("no route")
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Cannot connect"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_http_500(mock_redmine_class):
"""HTTP 500 превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
response = requests.Response()
response.status_code = 500
mock_redmine.user.get.side_effect = requests.exceptions.HTTPError(
"server error", response=response
)
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="server error"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_specified_user_id(mock_redmine_class):
"""user_id передаётся в time_entry.filter вместо текущего пользователя."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id=42)
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 42
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_resolves_user_login_to_id(mock_redmine_class):
"""Строковый логин разрешается в числовой ID."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_user = mock.MagicMock()
mock_user.id = 7
mock_redmine.user.filter.return_value = [mock_user]
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
login_filter_call = mock_redmine.user.filter.call_args_list[0]
assert login_filter_call.kwargs == {"login": "ivanov"}
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 7
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_resolves_user_name_to_id(mock_redmine_class):
"""Строковое имя разрешается в числовой ID, если найден ровно один пользователь."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.user.filter.side_effect = [
[], # поиск по логину не дал результатов
[mock.MagicMock(id=9)], # поиск по имени нашёл одного
]
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="Иванов И.И.")
name_filter_call = mock_redmine.user.filter.call_args_list[1]
assert name_filter_call.kwargs == {"name": "Иванов И.И."}
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 9
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_ambiguous_user_name(mock_redmine_class):
"""Если по имени найдено несколько пользователей — ошибка с понятным сообщением."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.user.filter.side_effect = [
[], # поиск по логину не дал результатов
[mock.MagicMock(id=1), mock.MagicMock(id=2)], # по имени — несколько
]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Multiple users match"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="Иванов")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_unknown_user_login(mock_redmine_class):
"""Если пользователь не найден — ошибка с предложением использовать --user-id."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.user.filter.return_value = []
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="not found"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="unknown")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_numeric_string_user_id(mock_redmine_class):
"""Строка из цифр интерпретируется как числовой ID."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="42")
assert mock_redmine.user.filter.call_count == 0
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 42
# -- #24: Таймаут и retry --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_sets_timeout_in_requests(mock_redmine_class):
"""В requests dict передаётся timeout (#24)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
_, kwargs = mock_redmine_class.call_args
assert "timeout" in kwargs["requests"]
assert kwargs["requests"]["timeout"] == 30
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_mounts_retry_adapter(mock_redmine_class):
"""На сессию монтируется HTTPAdapter с retry для временных ошибок (#24)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
# Проверяем, что engine.session.mount был вызван для http:// и https://
mount_calls = mock_redmine.engine.session.mount.call_args_list
prefixes = [call.args[0] for call in mount_calls]
assert "https://" in prefixes
assert "http://" in prefixes
# Проверяем retry-конфигурацию адаптера
https_adapter = next(call.args[1] for call in mount_calls if call.args[0] == "https://")
max_retries = https_adapter.max_retries
assert max_retries.total == 3
assert 429 in max_retries.status_forcelist
# -- #21: Чанкирование issue_ids --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_chunks_large_issue_count(mock_redmine_class):
"""При >100 задач запросы разбиваются на чанки (#21)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
# 250 time entries → 250 уникальных issue_id
entries = []
for i in range(1, 251):
e = mock.MagicMock()
e.issue.id = i
e.hours = 1.0
entries.append(e)
mock_redmine.time_entry.filter.return_value = entries
# issue.filter вызывается с чанками по 100 ID
call_chunks = []
def issue_filter_side_effect(**kwargs):
ids_str = kwargs.get("issue_id", "")
call_chunks.append(ids_str)
ids = [int(x) for x in ids_str.split(",")]
return [mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids]
mock_redmine.issue.filter.side_effect = issue_filter_side_effect
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
# Должно быть 3 вызова (100 + 100 + 50)
assert len(call_chunks) == 3
assert len(call_chunks[0].split(",")) == 100
assert len(call_chunks[1].split(",")) == 100
assert len(call_chunks[2].split(",")) == 50
assert result is not None
assert len(result) == 250
# -- #47: Дедупликация time entries по datetime --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
"""Записи с created_on/updated_on раньше dedup_before исключаются."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
e1.updated_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
e2 = mock.MagicMock()
e2.issue.id = 2
e2.hours = 1.0
e2.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e2.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
mock_redmine.time_entry.filter.return_value = [e1, e2]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_issue2.project = "P"
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
assert result is not None
assert len(result) == 1
assert result[0][0].id == 2
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_filters_entries_with_old_created_even_if_updated_recently(mock_redmine_class):
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
e1.updated_on = datetime(2026, 7, 1, 15, 0, 0, tzinfo=timezone.utc)
mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
"""Записи с created_on/updated_on >= dedup_before сохраняются."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1.updated_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
assert result is not None
assert len(result) == 1
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_entries_without_created_on_are_kept(mock_redmine_class):
"""Записи без created_on/updated_on не фильтруются (сохраняются)."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = None
e1.updated_on = None
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
assert result is not None
assert len(result) == 1
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_handles_string_created_on(mock_redmine_class):
"""created_on может быть строкой ISO — парсим корректно."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = "2026-07-01T10:00:00Z"
e1.updated_on = "2026-07-01T10:00:00Z"
mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_no_dedup_when_cutoff_is_none(mock_redmine_class):
"""Без dedup_before фильтрация не применяется."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = None
e1.updated_on = None
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=None)
assert result is not None
assert len(result) == 1
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
"""Смешанные записи: старые исключаются, новые сохраняются, аки идут."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e_old = mock.MagicMock()
e_old.issue.id = 10
e_old.hours = 1.0
e_old.created_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
e_old.updated_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc)
e_new = mock.MagicMock()
e_new.issue.id = 20
e_new.hours = 3.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e_akiy = mock.MagicMock()
e_akiy.issue.id = 30
e_akiy.hours = 5.0
e_akiy.created_on = None
e_akiy.updated_on = None
mock_redmine.time_entry.filter.return_value = [e_old, e_new, e_akiy]
mock_issue_new = mock.MagicMock()
mock_issue_new.id = 20
mock_issue_new.project = "P"
mock_issue_new.subject = "T"
mock_issue_new.status = "New"
mock_issue_akiy = mock.MagicMock()
mock_issue_akiy.id = 30
mock_issue_akiy.project = "P"
mock_issue_akiy.subject = "T2"
mock_issue_akiy.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
assert result is not None
assert len(result) == 2
ids = {r[0].id for r in result}
assert ids == {20, 30}

559
tests/test_config.py Normal file
View File

@@ -0,0 +1,559 @@
import os
import tempfile
from pathlib import Path
from unittest import mock
import pytest
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY, AppConfig, Config
@mock.patch.dict(
os.environ,
{
"REDMINE_URL": "https://red.eltex.loc/",
"REDMINE_USER": "test",
"REDMINE_PASSWORD": "secret",
},
clear=True,
)
def test_config_valid_with_password_fallback():
Config.validate() # не должно быть исключения
@mock.patch.dict(
os.environ,
{
"REDMINE_URL": "https://red.eltex.loc/",
"REDMINE_API_KEY": "token",
},
clear=True,
)
def test_config_valid_with_api_key():
Config.validate() # не должно быть исключения
@mock.patch.dict(os.environ, {}, clear=True)
def test_config_missing_url():
with pytest.raises(ValueError, match="REDMINE_URL"):
Config.validate()
@mock.patch.dict(os.environ, {"REDMINE_URL": "https://red.eltex.loc/"}, clear=True)
def test_config_missing_auth():
with pytest.raises(ValueError, match="REDMINE_API_KEY"):
Config.validate()
@mock.patch.dict(os.environ, {"REDMINE_URL": " https://red.eltex.loc/ "}, clear=True)
def test_get_redmine_url_strips_spaces_and_trailing_slash():
assert Config.get_redmine_url() == "https://red.eltex.loc"
@mock.patch.dict(os.environ, {"REDMINE_AUTHOR": " Иванов И.И. "}, clear=True)
def test_get_author():
assert Config.get_author("") == "Иванов И.И."
assert Config.get_author("Петров П.П.") == "Петров П.П."
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_author_fallback():
"""Если ни CLI, ни .env не задали автора -- возвращается пустая строка."""
assert Config.get_author("") == ""
@mock.patch.dict(
os.environ,
{
"DEFAULT_FROM_DATE": "2026-01-01",
"DEFAULT_TO_DATE": "2026-01-31",
},
clear=True,
)
def test_get_default_date_range_from_env():
assert Config.get_default_date_range() == "2026-01-01--2026-01-31"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_default_date_range_fallback():
"""Если даты не заданы -- используется текущий месяц."""
from datetime import date, timedelta
today = date.today()
start = today.replace(day=1)
if today.month == 12:
next_month = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month = today.replace(month=today.month + 1, day=1)
end = next_month - timedelta(days=1)
result = Config.get_default_date_range()
assert result == f"{start.isoformat()}--{end.isoformat()}"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_redmine_verify_default():
assert Config.get_redmine_verify() == DEFAULT_REDMINE_VERIFY
@pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"])
def test_get_redmine_verify_false_values(value):
with mock.patch.dict(os.environ, {"REDMINE_VERIFY": value}, clear=True):
assert Config.get_redmine_verify() is False
@pytest.mark.parametrize("value", ["1", "true", "True", "yes", "on"])
def test_get_redmine_verify_true_values(value):
with mock.patch.dict(os.environ, {"REDMINE_VERIFY": value}, clear=True):
assert Config.get_redmine_verify() is True
@mock.patch.dict(os.environ, {"REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True)
def test_get_redmine_verify_custom_path():
assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem"
# -- #15: .env не должен переопределять переменные окружения --
@mock.patch("dotenv.load_dotenv")
def test_env_var_takes_priority_over_dotenv(mock_load):
"""load_dotenv вызывается с override=False — env vars не перебиваются .env."""
import importlib
from redmine_reporter import config as cfg_mod
importlib.reload(cfg_mod)
mock_load.assert_called_once_with(override=False)
# -- #35: get_redmine_password должен делать .strip() --
@mock.patch.dict(os.environ, {"REDMINE_PASSWORD": " secret123 "}, clear=True)
def test_get_redmine_password_strips_whitespace():
"""Пароль обрезается от whitespace, как и все остальные геттеры."""
assert Config.get_redmine_password() == "secret123"
# ---------------------------------------------------------------------------
# AppConfig tests — YAML config loading
# ---------------------------------------------------------------------------
FULL_YAML = """\
redmine:
url: https://redmine.example.com/
api_key: ${MISSING_API_KEY_FOR_TEST}
author: "Тестовый Автор"
verify_ssl: false
period:
precision: datetime
default_from: "2026-06-01"
default_to: "2026-06-30"
dynamic: true
last_used:
from: "2026-06-30T09:00:00"
to: "2026-06-30T12:00:00"
output:
dir: ~/reports
filename: "{author}_{from}_{to}.{ext}"
default_format: xlsx
email:
smtp:
host: smtp.example.com
port: 587
user: bot@example.com
password: ${MISSING_SMTP_PASSWORD_FOR_TEST}
tls: true
from: bot@example.com
to:
- boss@example.com
cc: []
bcc: []
subject: "Отчёт {author} за {period}"
body_text: "Во вложении отчёт."
attach: true
"""
class TestAppConfigFromYaml:
"""Tests for AppConfig.from_yaml()."""
def test_loads_all_sections_from_valid_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(FULL_YAML)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_url == "https://redmine.example.com/"
assert cfg.redmine_api_key == "" # ${MISSING_API_KEY_FOR_TEST} not set
assert cfg.redmine_author == "Тестовый Автор"
assert cfg.redmine_verify is False
assert cfg.period_precision == "datetime"
assert cfg.period_default_from == "2026-06-01"
assert cfg.period_default_to == "2026-06-30"
assert cfg.period_dynamic is True
assert cfg.period_last_used_from == "2026-06-30T09:00:00"
assert cfg.period_last_used_to == "2026-06-30T12:00:00"
assert cfg.output_dir == "~/reports"
assert cfg.output_filename == "{author}_{from}_{to}.{ext}"
assert cfg.output_default_format == "xlsx"
assert cfg.email.smtp.host == "smtp.example.com"
assert cfg.email.smtp.port == 587
assert cfg.email.smtp.user == "bot@example.com"
assert cfg.email.smtp.password == ""
assert cfg.email.smtp.tls is True
assert cfg.email.from_ == "bot@example.com"
assert cfg.email.to == ["boss@example.com"]
assert cfg.email.subject == "Отчёт {author} за {period}"
def test_partial_yaml_uses_defaults_for_missing(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n url: https://x.com/\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_url == "https://x.com/"
# defaults for everything else
assert cfg.redmine_author == ""
assert cfg.period_precision == "date"
assert cfg.output_dir == ""
assert cfg.email.to == []
def test_empty_file_returns_all_defaults(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_url == ""
assert cfg.redmine_api_key == ""
assert cfg.period_precision == "date"
def test_file_not_found_returns_all_defaults(self):
cfg = AppConfig.from_yaml("/nonexistent/path/config.yml")
assert cfg.redmine_url == ""
@mock.patch.dict(os.environ, {"REDMINE_API_KEY": "secret-token"}, clear=True)
def test_env_var_resolved_in_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n api_key: ${REDMINE_API_KEY}\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_api_key == "secret-token"
def test_verify_ssl_true_returns_default_ca_path(self):
"""verify_ssl: true → DEFAULT_REDMINE_VERIFY (путь), не Python True."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n verify_ssl: true\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify == DEFAULT_REDMINE_VERIFY
assert cfg.redmine_verify is not True # не бул!
def test_verify_ssl_false_returns_false(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n verify_ssl: false\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify is False
def test_missing_env_var_logs_warning(self, caplog):
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n api_key: ${MISSING_KEY}\n")
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_api_key == ""
assert "MISSING_KEY" in caplog.text
def test_unknown_top_level_key_logs_warning(self, caplog):
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("unknown_section:\n foo: bar\n")
with caplog.at_level(logging.WARNING):
AppConfig.from_yaml(yaml_path)
assert "unknown_section" in caplog.text
# ---------------------------------------------------------------------------
# Config priority chain tests — YAML fallback
# ---------------------------------------------------------------------------
class TestConfigYamlFallback:
"""Tests that Config.get_*() falls back to YAML when env/CLI not set."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_url_from_yaml_when_no_env_or_cli(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n")
Config._cli_url = None
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_redmine_url() == "https://yaml-redmine.example.com"
@mock.patch.dict(os.environ, {}, clear=True)
def test_cli_beats_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n")
Config._app = AppConfig.from_yaml(yaml_path)
Config.set_redmine_url("https://cli-override.example.com")
assert Config.get_redmine_url() == "https://cli-override.example.com"
@mock.patch.dict(os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True)
def test_env_beats_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n")
Config._cli_url = None
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_redmine_url() == "https://env-redmine.example.com"
@mock.patch.dict(os.environ, {}, clear=True)
def test_date_range_from_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_default_date_range() == "2026-03-01--2026-03-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_author_from_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n author: 'Автор Из Ямла'\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_author("") == "Автор Из Ямла"
@mock.patch.dict(os.environ, {}, clear=True)
def test_validate_passes_with_yaml_url_and_api_key(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"redmine:\n url: https://yaml.example.com/\n api_key: yaml-token\n"
)
Config._cli_url = None
Config._cli_api_key = None
Config._app = AppConfig.from_yaml(yaml_path)
# Не должно быть исключения
Config.validate()
@mock.patch.dict(os.environ, {}, clear=True)
def test_default_date_range_env_beats_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
with mock.patch.dict(
os.environ,
{"DEFAULT_FROM_DATE": "2026-07-01", "DEFAULT_TO_DATE": "2026-07-15"},
clear=True,
):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
class TestConfigPeriodPrecision:
"""Tests for period.precision and period.last_used."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_period_precision_defaults_to_date(self):
"""Без YAML-конфига precision == 'date'."""
Config._app = None
assert Config.get_period_precision() == "date"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_period_precision_from_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n precision: datetime\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_period_precision() == "datetime"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_last_used_from_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n last_used:\n from: '2026-06-30T09:00:00'\n to: '2026-06-30T12:00:00'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_last_used_from() == "2026-06-30T09:00:00"
assert Config.get_last_used_to() == "2026-06-30T12:00:00"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_last_used_returns_empty_when_not_set(self):
Config._app = AppConfig()
assert Config.get_last_used_from() == ""
assert Config.get_last_used_to() == ""
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_loads_last_used_field(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n last_used:\n from: '2026-07-01T08:00:00'\n to: '2026-07-01T18:00:00'\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_last_used_from == "2026-07-01T08:00:00"
assert cfg.period_last_used_to == "2026-07-01T18:00:00"
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_without_last_used_has_empty_fields(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n precision: date\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_last_used_from == ""
assert cfg.period_last_used_to == ""
class TestComputeNextPeriod:
"""Tests for compute_next_period()."""
def test_full_month_goes_to_next_full_month(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-01", "2026-06-30", "date")
assert nf == "2026-07-01"
assert nt == "2026-07-31"
def test_december_to_next_year_january(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2025-12-01", "2025-12-31", "date")
assert nf == "2026-01-01"
assert nt == "2026-01-31"
def test_february_2026_non_leap_to_march(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-02-01", "2026-02-28", "date")
assert nf == "2026-03-01"
assert nt == "2026-03-31"
def test_arbitrary_range_same_length_from_to_plus_one(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-15", "2026-06-20", "date")
assert nf == "2026-06-21"
assert nt == "2026-06-26"
def test_single_day_range_moves_one_day(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-15", "2026-06-15", "date")
assert nf == "2026-06-16"
assert nt == "2026-06-16"
def test_cross_month_range(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-25", "2026-07-05", "date")
assert nf == "2026-07-06"
assert nt == "2026-07-16"
def test_datetime_precision_moves_by_seconds(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime")
assert nf == "2026-06-30T12:00:01"
assert nt == "2026-06-30T15:00:01"
class TestDefaultDateRangeWithLastUsed:
"""Tests that get_default_date_range() uses last_used when dynamic=True."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_uses_last_used_when_dynamic_true(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: true\n"
" last_used:\n"
" from: '2026-05-01'\n"
" to: '2026-05-31'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-06-01--2026-06-30"
@mock.patch.dict(os.environ, {}, clear=True)
def test_ignores_last_used_when_dynamic_false(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-03-01'\n"
" default_to: '2026-03-15'\n"
" last_used:\n"
" from: '2026-06-01'\n"
" to: '2026-06-30'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-03-01--2026-03-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_falls_back_when_no_last_used_even_with_dynamic(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: true\n"
" default_from: '2026-04-01'\n"
" default_to: '2026-04-15'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-04-01--2026-04-15"

546
tests/test_formatters.py Normal file
View File

@@ -0,0 +1,546 @@
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": "",
"hours": 2.0,
},
{
"project": "Проект A",
"version": "v2.0",
"display_project": "",
"display_version": "v2.0",
"issue_id": 103,
"subject": "Документация Z",
"status_ru": "Ожидание",
"time_text": "",
"hours": 1.0,
},
{
"project": "Проект B",
"version": "<N/A>",
"display_project": "Проект B",
"display_version": "<N/A>",
"issue_id": 201,
"subject": "Обновить README",
"status_ru": "Закрыто",
"time_text": "",
"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": "",
"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_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&amp;B &quot;&lt;Project&gt;&quot;" in output
assert "101. Fix &lt;tag&gt; &amp; 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
# -- Тесты 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

View File

@@ -0,0 +1,279 @@
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"] == ""
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": "",
"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": "",
"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": "",
},
{
"project": "A",
"version": "v2",
"issue_id": 2,
"subject": "T2",
"status_ru": "S",
"time_text": "",
},
{
"project": "B",
"version": "v1",
"issue_id": 3,
"subject": "T3",
"status_ru": "S",
"time_text": "",
},
]
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": "",
},
{
"project": "A",
"version": "v1",
"issue_id": 2,
"subject": "T2",
"status_ru": "S",
"time_text": "",
},
{
"project": "A",
"version": "v1",
"issue_id": 3,
"subject": "T3",
"status_ru": "S",
"time_text": "",
},
]
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": "",
},
]
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"] == ""
def test_group_rows_empty():
"""Пустой список — пустой словарь."""
assert group_rows_by_project_and_version([]) == {}

112
tests/test_utils.py Normal file
View 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) == ""
assert hours_to_human(-1) == ""
def test_hours_to_human_whole_hours():
assert hours_to_human(1.0) == ""
assert hours_to_human(8.0) == ""
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"

320
tests/test_yaml_config.py Normal file
View File

@@ -0,0 +1,320 @@
import logging
import os
import tempfile
from pathlib import Path
from unittest import mock
from redmine_reporter.yaml_config import (
check_file_permissions,
ensure_config_dir,
expand_filename_template,
resolve_env_vars,
)
class TestResolveEnvVars:
"""Tests for ${VAR} resolution."""
@mock.patch.dict(os.environ, {"MY_VAR": "hello"}, clear=True)
def test_replaces_var_with_env_value(self):
assert resolve_env_vars("prefix_${MY_VAR}_suffix") == "prefix_hello_suffix"
@mock.patch.dict(os.environ, {}, clear=True)
def test_missing_var_returns_empty_and_warns(self, caplog):
with caplog.at_level(logging.WARNING):
result = resolve_env_vars("${MISSING_VAR}")
assert result == ""
assert "MISSING_VAR" in caplog.text
def test_no_braces_returns_unchanged(self):
assert resolve_env_vars("plain text no vars") == "plain text no vars"
@mock.patch.dict(os.environ, {"A": "${B}", "B": "final"}, clear=True)
def test_non_recursive_no_double_resolution(self):
# A → literal "${B}", B → "final". Non-recursive means we get "${B}", not "final".
result = resolve_env_vars("${A}")
assert result == "${B}"
class TestEnsureConfigDir:
"""Tests for config directory creation."""
def test_creates_dir_with_0700(self):
with tempfile.TemporaryDirectory() as tmp:
config_dir = Path(tmp) / "test_config"
result = ensure_config_dir(config_dir)
assert result == config_dir
assert result.exists()
assert result.is_dir()
mode = result.stat().st_mode & 0o777
assert mode == 0o700
def test_noop_if_dir_exists(self):
with tempfile.TemporaryDirectory() as tmp:
config_dir = Path(tmp) / "existing"
config_dir.mkdir(mode=0o700)
mtime_before = config_dir.stat().st_mtime
result = ensure_config_dir(config_dir)
assert result == config_dir
assert config_dir.stat().st_mtime == mtime_before
class TestCheckFilePermissions:
"""Tests for config file permission checks."""
def test_warns_on_permissive_file(self):
with tempfile.TemporaryDirectory() as tmp:
f = Path(tmp) / "open.yml"
f.write_text("key: value")
f.chmod(0o644)
warnings = check_file_permissions(f)
assert len(warnings) >= 1
assert "0644" in warnings[0] or "permissions" in warnings[0].lower()
def test_silent_on_0600(self):
with tempfile.TemporaryDirectory() as tmp:
f = Path(tmp) / "secure.yml"
f.write_text("key: value")
f.chmod(0o600)
warnings = check_file_permissions(f)
assert warnings == []
class TestExpandFilenameTemplate:
"""Tests for filename template expansion."""
def test_expands_all_placeholders(self):
result = expand_filename_template(
"report_{author}_{from}_{to}.{ext}",
author="Кокос А.А.",
from_date="2026-06-01",
to_date="2026-06-30",
ext="xlsx",
)
assert result == "report_Кокос_А.А._2026-06-01_2026-06-30.xlsx"
def test_date_placeholder_dd_mm_yyyy(self):
result = expand_filename_template(
"отчёт_{date}.{ext}",
author="Кокос А.А.",
from_date="2026-03-01",
to_date="2026-03-31",
ext="odt",
)
assert result == "отчёт_31_03_2026.odt"
def test_no_placeholders_returns_unchanged(self):
result = expand_filename_template(
"report.odt",
author="Кокос А.А.",
from_date="2026-01-01",
to_date="2026-01-31",
ext="odt",
)
assert result == "report.odt"
def test_unknown_placeholder_left_as_is(self):
result = expand_filename_template(
"{author}_{unknown}.{ext}",
author="Кокос А.А.",
from_date="2026-01-01",
to_date="2026-01-31",
ext="xlsx",
)
assert result == "Кокос_А.А._{unknown}.xlsx"
class TestResolveOutputPath:
"""Tests for resolve_output_path()."""
def test_none_returns_none(self):
from redmine_reporter.yaml_config import resolve_output_path
assert resolve_output_path(None) is None
def test_explicit_path_with_known_extension_is_unchanged(self):
from redmine_reporter.yaml_config import resolve_output_path
result = resolve_output_path("/tmp/report.xlsx")
assert result == "/tmp/report.xlsx"
def test_explicit_path_with_unknown_extension_is_unchanged(self):
from redmine_reporter.yaml_config import resolve_output_path
result = resolve_output_path("report.odt")
assert result == "report.odt"
def test_no_extension_appends_default_format(self):
from redmine_reporter.yaml_config import resolve_output_path
result = resolve_output_path("report", default_format="xlsx")
assert result == "report.xlsx"
def test_no_extension_appends_custom_default_format(self):
from redmine_reporter.yaml_config import resolve_output_path
result = resolve_output_path("file", default_format="csv")
assert result == "file.csv"
def test_bare_format_name_resolves_to_template_path(self):
from redmine_reporter.yaml_config import resolve_output_path
result = resolve_output_path(
"xlsx",
output_dir="/tmp/reports",
filename_template="{date}.{ext}",
author="Кокос А.А.",
from_date="2026-06-01",
to_date="2026-06-30",
default_format="xlsx",
)
assert result == "/tmp/reports/30_06_2026.xlsx"
def test_bare_format_name_odt_resolves_to_template_path(self):
from redmine_reporter.yaml_config import resolve_output_path
result = resolve_output_path(
"odt",
output_dir="~/reports",
filename_template="{author}_{from}_{to}.{ext}",
author="Кокос А.А.",
from_date="2026-06-01",
to_date="2026-06-30",
default_format="xlsx",
)
assert result.startswith("/") and result.endswith(
"/reports/Кокос_А.А._2026-06-01_2026-06-30.odt"
)
assert "~" not in result
def test_bare_format_name_with_empty_output_dir_uses_cwd(self):
from redmine_reporter.yaml_config import resolve_output_path
result = resolve_output_path(
"csv",
output_dir="",
filename_template="report.{ext}",
author="A",
from_date="2026-01-01",
to_date="2026-01-31",
)
assert not result.startswith("/")
assert "report.csv" in result
def test_unknown_bare_format_is_treated_as_path(self):
from redmine_reporter.yaml_config import resolve_output_path
result = resolve_output_path("unknown_format")
assert result == "unknown_format.xlsx"
class TestSavePeriodToConfig:
"""Tests for save_period_to_config()."""
def test_creates_last_used_in_empty_config(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
assert "default_from" not in data["period"]
def test_overwrites_existing_last_used(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
config_path.write_text(
"period:\n" " last_used:\n" " from: '2026-05-01'\n" " to: '2026-05-31'\n"
)
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
def test_dynamic_false_overwrites_defaults(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
config_path.write_text(
"period:\n" " default_from: '2026-01-01'\n" " default_to: '2026-01-31'\n"
)
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", False)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["default_from"] == "2026-06-01"
assert data["period"]["default_to"] == "2026-06-30"
assert data["period"]["last_used"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
def test_datetime_precision_stores_timestamps(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
save_period_to_config(
str(config_path),
"2026-06-30T09:00:00",
"2026-06-30T12:00:00",
"datetime",
True,
)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-30T09:00:00"
assert data["period"]["last_used"]["to"] == "2026-06-30T12:00:00"
def test_preserves_existing_sections(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
config_path.write_text(
"redmine:\n"
" url: https://example.com\n"
" author: Test\n"
"output:\n"
" dir: /tmp\n"
)
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["redmine"]["url"] == "https://example.com"
assert data["redmine"]["author"] == "Test"
assert data["output"]["dir"] == "/tmp"
assert data["period"]["last_used"]["from"] == "2026-06-01"
def test_sets_permissions_0600(self, tmp_path):
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
mode = config_path.stat().st_mode & 0o777
assert mode == 0o600