31 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
3accb1212c fix: use OS trust store for TLS verification via truststore
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
Regression from #62: verify_ssl true used to resolve to the system CA
bundle path, so corporate CAs installed in the OS worked; after the
unification true became requests' default (certifi), breaking setups
with a corporate CA in the system store.

Now verify_ssl true injects truststore, so requests verifies against
the OS trust store on any platform. verify_ssl false / custom CA path
behavior is unchanged. Tests mock truststore via an autouse fixture to
keep the pytest process free of global ssl mutation.

Refs #62
2026-07-17 18:39:53 +07:00
Кокос Артем Николаевич
3b9cfdcf7d chore: bump version to 1.11.0
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
2026-07-17 17:57:31 +07:00
Кокос Артем Николаевич
c8df40fe5c docs: rewrite README, add user guide, sync CONFIG.md with code
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
- README.md: 324 -> 106 lines, clickable TOC, quick start, formats
  table, full check suite + CI; flags/env/YAML reference moved to docs
- docs/USER_GUIDE.md: new user guide (install, periods, users, export,
  email, monthly --commit cycle, 21-flag reference, troubleshooting)
- docs/CONFIG.md: clickable TOC + accuracy fixes verified against code
  (dedup logic was described backwards, missing report section in
  --init-config, conditional STARTTLS, full --config-path role,
  datetime --date ranges, --config override nuance, comment loss on
  --commit, default_to without default_from is ignored)
- client.py: dedup docstring/comment now match actual behavior
2026-07-17 17:48:52 +07:00
Кокос Артем Николаевич
1dc19f8c1a ci: add Gitea Actions workflow for tests and linters
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
- .gitea/workflows/checks.yaml: runs on push to main and every PR,
  matrix Python 3.10-3.13 (matches requires-python >=3.10), steps:
  isort, black, ruff check, ruff format, mypy, pytest
- dev extras gain black, isort (needed by pip install -e .[dev] in CI)
  and setuptools>=61.0 (test_formatters_found_by_setuptools imports
  find_packages at runtime; Python 3.12+ venvs no longer bundle it)
- README gains pipeline status badge

Closes #53
2026-07-17 15:49:27 +07:00
Кокос Артем Николаевич
29e7615d20 chore: configure isort to use black profile
isort --check-only failed on 6 files because the project had no isort
configuration and its default style conflicts with black/ruff-format.
With profile = "black" all formatters agree; no source changes needed.
2026-07-17 15:25:52 +07:00
Кокос Артем Николаевич
8614062ecd feat: status translation overrides via YAML config
report.status_translation in YAML config overrides/extends the builtin
STATUS_TRANSLATION dictionary; without the section behavior is unchanged.

- AppConfig.report_status_translation + _resolve_str_dict (warns and
  skips non-scalar values, resolves ${VAR} references)
- Config.get_status_translation() returns merged copy (lazy import,
  builtin dict never mutated)
- build_grouped_report() accepts optional status_translation parameter
- --init-config template gains commented example, docs/CONFIG.md updated

Closes #65
2026-07-17 15:18:27 +07:00
Кокос Артем Николаевич
09f6062e8c fix: restore .env loading for --init-config
Регрессия от 1df1194 (#64): load_dotenv переехал с module-level в
Config.load_yaml(), но --init-config выходит до этого вызова, поэтому
_run_init_config больше не видел значения из .env — пользователь с
настройками только в .env получал пустой YAML, хотя docs/CONFIG.md
обещает чтение текущих значений из .env.

В начале _run_init_config добавлен load_dotenv(find_dotenv(usecwd=True),
override=False): поиск .env идёт от текущей директории (plain-вызов без
usecwd стартует от директории cli.py и .env пользователя не находит),
переменные окружения не перебиваются. Импорт модуля по-прежнему без
side effects — вызов внутри функции.

Регрессионный тест: test_init_config_reads_dotenv_file (реальный .env
в tmp_path + chdir, без мока os.environ на целевые переменные).

Refs #64
2026-07-17 14:45:31 +07:00
Кокос Артем Николаевич
eef09538e6 Merge branch 'refactor/63-dead-code' 2026-07-17 13:51:53 +07:00
Кокос Артем Николаевич
da1bd72332 Merge branch 'refactor/62-64-56-config' 2026-07-17 13:51:53 +07:00
Кокос Артем Николаевич
1c0ada2baf refactor: remove dead code in cli and xlsx
cli.py: drop redundant 'if issue_hours is None' branch before
'if not issue_hours'. The branch is not formally dead (client
returns None when no time entries found, client.py), but it is a
semantic duplicate: both branches print the same message and
return 0, and None is falsy, so a single 'not issue_hours' check
covers None, empty list and keeps behavior identical.
test_cli_returns_zero_on_no_entries stays green unchanged.

xlsx.py: drop unreachable 'if ws is None' fallback. Workbook()
(write_only=False) always creates one worksheet in __init__, so
wb.active is never None; set the title directly.

Closes #63
2026-07-17 13:41:57 +07:00
Кокос Артем Николаевич
1683b0f893 fix: default report period to current month
Без --date, env и YAML отчёт строится за текущий месяц: начало периода —
1-е число текущего месяца, конец — сегодня (date.today()), согласовано с
fallback default_to→today из #50. Приоритеты источников
(CLI > env > .env > YAML > дефолт) и precision=datetime не затронуты.

Сам fallback уже существовал в коде (хардкод устаревшего периода был
убран ранее); коммит фиксирует поведение детерминированными тестами с
моком date.today (RED проверен регрессионным зондом: при возврате
хардкода 2025-12-19--2026-01-31 тесты падают) и обновляет README и
docs/CONFIG.md: дефолт явно описан как «текущий месяц».

Closes #56
2026-07-17 13:38:14 +07:00
Кокос Артем Николаевич
1df1194f58 refactor: remove import-time side effects
Импорт модулей больше не мутирует процесс:

- config.py: module-level load_dotenv(override=False) мутировал os.environ
  при любом импорте. Вызов перенесён в Config.load_yaml() — точку явной
  загрузки конфигурации, семантика override=False и путь по умолчанию
  сохранены. --config PATH (load_dotenv(path, override=True)) не затронут.
- mailer.py: глобальная регистрация email.charset.add_charset('utf-8', ...)
  меняла кодировку для всего процесса. Заменена на точечное применение
  8bit UTF-8 к телу письма в _utf8_text_part() — payload в UTF-8
  (surrogateescape) + Content-Transfer-Encoding: 8bit на конкретной части.
  Проверено: сериализация в байты (BytesGenerator, как в send_message)
  побайтово совпадает со старым поведением.

Тест test_env_var_takes_priority_over_dotenv адаптирован к новой точке
вызова load_dotenv; test_body_substitution переведён с as_string() на
декодированный payload (str-сериализация не-ASCII части без глобальной
мутации реестра теперь даёт base64; wire-формат 8bit сохранён и
зафиксирован test_wire_format_is_8bit_utf8).

Closes #64
2026-07-17 13:35:02 +07:00
Кокос Артем Николаевич
829f1b73fd fix: unify verify_ssl semantics across config sources
YAML verify_ssl: true раньше подставлял захардкоженный путь
/etc/ssl/certs/ca-certificates.crt, а REDMINE_VERIFY=true — bool True.
Путь отсутствует на части дистрибутивов, семантика источников различалась.

Теперь едино для YAML и env:
- true (bool/строка) → True: стандартная проверка TLS средствами requests;
- false (bool/строка) → False (+ сохраняется warning из #57);
- иная строка → путь к CA-bundle как есть.
- дефолт (значение не задано) → True вместо захардкоженного пути.

Существующие тесты на путь-от-true переписаны под новую семантику
(изменение поведения): test_verify_ssl_true_returns_default_ca_path →
test_verify_ssl_true_returns_true.

Closes #62
2026-07-17 13:28:32 +07:00
Кокос Артем Николаевич
debdede97a Merge branch 'fix/55-cli-sanitize' 2026-07-17 12:57:43 +07:00
Кокос Артем Николаевич
fa428e42fa Merge branch 'fix/54-57-config-security' 2026-07-17 12:57:43 +07:00
Кокос Артем Николаевич
fb1ca1d9b8 fix: sanitize Redmine exception output in CLI
Redmine/requests exceptions may embed the request URL containing the
API key (?key=SECRET) or the X-Redmine-API-Key header value. The CLI
printed the raw exception text to stderr, leaking the key into
terminals and logs.

Add _sanitize_error_text() which masks 'key=<value>' query params and
the X-Redmine-API-Key header as 'key=***' / 'X-Redmine-API-Key: ***',
and apply it to every exception-derived message printed to stderr
(fetch errors in main() and SMTP errors in _save_and_maybe_send()).

Full traceback with original exception details is now available under
both --verbose and --debug (previously only --debug, only for
RedmineAPIError). Exit codes and error-handling structure unchanged.

Closes #55
2026-07-17 12:51:23 +07:00
Кокос Артем Николаевич
b624a8b8c2 fix: warn when TLS verification is disabled
REDMINE_VERIFY=false / verify_ssl: false silently disabled TLS
certificate verification, exposing the connection to MITM attacks.
Config.validate() now prints a warning to stderr when verification
is disabled (exactly False; True or a CA-bundle path stays quiet).
validate() runs exactly once at CLI startup, so the warning is
emitted once per run and is visible in every scenario.

Closes #57
2026-07-17 12:50:40 +07:00
Кокос Артем Николаевич
a1febd6999 fix: require HTTPS for Redmine URL
Config.validate() now rejects REDMINE_URL values whose scheme is not
HTTPS (http://, missing scheme, or any other scheme). The API key is
sent in request headers, so a plain-HTTP endpoint would expose it.
Scheme comparison is case-insensitive per RFC 3986.

Closes #54
2026-07-17 12:48:45 +07:00
Кокос Артем Николаевич
594db90227 fix: warn on silent data loss in report
Issues not returned by issue.filter (no access / deleted) and failures
of the time entry activities lookup were silently swallowed: the report
was built from partial data without telling the user.

Now both cases emit a warning to stderr (project ⚠️ style, #28):
- skipped issue IDs and total lost hours after issue matching;
- activities lookup failure (still returns {} and falls back to
  activity names from time entries).

These are warnings, not errors: the report is still built from
available data and the exit code is unchanged.

Closes #61
2026-07-17 12:27:50 +07:00
Кокос Артем Николаевич
598f2d35a1 fix: resolve --user-login by exact match
Redmine user.filter(login=...) performs an inexact substring search, but
_resolve_user_id took users[0].id unconditionally, so a report could
silently be built for the wrong user (e.g. 'ivanov' matching 'ivanov2').

Now only exact (case-sensitive) login matches are considered: exactly one
match resolves to its id, multiple matches raise an ambiguity error,
no match falls through to the name lookup and then to a 'not found'
error — symmetric with the --user-name resolution.

Closes #60
2026-07-17 12:21:47 +07:00
Кокос Артем Николаевич
46674ba926 fix: normalize datetimes to aware UTC in dedup
Dedup with precision=datetime crashed with TypeError: redminelib returns
naive created_on/updated_on while the cutoff from _compute_dedup_cutoff
is aware UTC. Normalize at a single point: _parse_datetime now always
returns an aware datetime (naive treated as UTC), matching its docstring.
The dedup cutoff is normalized the same way, and any residual comparison
TypeError is wrapped in RedmineAPIError instead of leaking raw.

Also fix --commit saving last_used.to as naive local time; it now stores
aware UTC (datetime.now(timezone.utc)) so the next run computes a correct
aware cutoff.

Closes #58
2026-07-17 12:11:35 +07:00
Кокос Артем Николаевич
c4ec23048a fix: accept datetime ranges in parse_date_range
With period.dynamic: true and period.precision: datetime, running
without --date crashed: compute_next_period() returns a datetime range
(YYYY-MM-DDTHH:MM:SS), which parse_date_range() rejected with a
fullmatch against YYYY-MM-DD only.

parse_date_range now accepts YYYY-MM-DDTHH:MM:SS--YYYY-MM-DDTHH:MM:SS
in addition to plain date ranges, returning datetime strings unchanged
(pass-through, matching compute_next_period output). Mixed-precision
ranges, invalid times and reversed ranges are still rejected with the
existing error messages; date-range behavior is unchanged.

Closes #59
2026-07-17 11:44:13 +07:00
Кокос Артем Николаевич
d135408f5e test: strengthen #58 dedup trap to assert filtering
The trap mocked issue.filter to return only issue 2, so result == [2]
would pass even with a wrong fix that suppresses TypeError but keeps all
entries. Now the API mock returns both issues; only correct filtering
of the pre-cutoff time entry keeps the result at [2].

Refs #67, #58
2026-07-17 11:22:10 +07:00
Кокос Артем Николаевич
3a3a7bb39c test: fix cli mocks, add pytest config and coverage gaps
- tests/test_cli.py: fix 26 fetch_issues_with_spent_time mocks to return
  3-element tuples (issue, hours, activities) matching the real signature
- pyproject.toml: add [tool.pytest.ini_options] with testpaths and
  pythonpath so bare pytest works
- tests/test_client.py: add pagination test (>100 time entries arriving
  in pages, hours aggregated across all pages)
- tests/test_formatters.py: add structural tests — XLSX full header row
  and grand total row via openpyxl, HTML thead with all columns and
  tbody row count
- add strict xfail trap-tests for known bugs: #58 (naive created_on vs
  aware dedup cutoff TypeError) and #59 (parse_date_range rejects
  datetime range from dynamic+datetime period)

Closes #67
2026-07-17 10:59:47 +07:00
Кокос Артем Николаевич
7ca58f881e fix: declare direct dependencies requests and urllib3
redmine_reporter/client.py imports requests and urllib3 directly but
they were only available transitively via python-redmine. Retry with
allowed_methods requires urllib3 >= 1.26.

Closes #66
2026-07-17 10:44:14 +07:00
Кокос Артем Николаевич
e587684ad7 fix: require Python >= 3.10
pyproject.toml declared requires-python >= 3.9, but the codebase uses
annotations that are incompatible with Python 3.9: builtin generic
tuple[str, str] in parse_date_range (cli.py) and PEP 604 unions
str | None (config.py, yaml_config.py), the latter requiring 3.10+
at runtime.

Bump requires-python to >=3.10 and drop the Python 3.9 classifier.

Closes #51
2026-07-17 10:33:33 +07:00
Кокос Артем Николаевич
5f51d40fef Merge branch 'fix/default-to-today' 2026-07-10 16:52:55 +07:00
Кокос Артем Николаевич
22f1733a5d refactor: use single date.today() call in get_default_date_range
Avoids potential midnight inconsistency between start and end of the
fallback period. Related to #50.
2026-07-10 16:50:44 +07:00
Кокос Артем Николаевич
17b0e99aa3 chore: bump version to 1.10.1 and update README
- Update README to document optional DEFAULT_TO_DATE / default_to
- Default end date falls back to today when not explicitly set

Closes #50
2026-07-10 16:46:30 +07:00
Кокос Артем Николаевич
8992bb922e docs: explain optional period.default_to and today fallback 2026-07-10 16:40:22 +07:00
Кокос Артем Николаевич
608afe08e3 fix: default missing period.default_to to today 2026-07-10 16:33:28 +07:00
19 changed files with 2117 additions and 391 deletions

View File

@@ -0,0 +1,26 @@
name: checks
on:
push:
branches: [main]
pull_request:
jobs:
checks:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -e .[dev]
- run: isort --check-only redmine_reporter tests
- run: black --check redmine_reporter tests
- run: ruff check redmine_reporter tests
- run: ruff format --check redmine_reporter tests
- run: mypy redmine_reporter
- run: pytest -v

309
README.md
View File

@@ -1,32 +1,33 @@
# redmine-reporter
CLI-инструмент для генерации отчётов по задачам Redmine на основе записей о затраченном времени.
[![checks](https://git.akokos.ru/artem.kokos/redmine-reporter/actions/workflows/checks.yaml/badge.svg)](https://git.akokos.ru/artem.kokos/redmine-reporter/actions)
Проект предназначен для внутреннего использования с `https://red.eltex.loc/`.
- [Возможности](#возможности)
- [Установка](#установка)
- [Быстрый старт](#быстрый-старт)
- [Документация](#документация)
- [Форматы вывода](#форматы-вывода)
- [Разработка](#разработка)
- [Безопасность](#безопасность)
- [Лицензия](#лицензия)
Лицензия: MIT.
CLI-инструмент для генерации отчётов по задачам Redmine на основе записей о затраченном времени. Читает time entries текущего или указанного пользователя, группирует задачи по проекту и версии, выводит отчёт в консоль или экспортирует в файл. Предназначен для внутреннего использования с `https://red.eltex.loc/`.
## Возможности
- Получение time entries **текущего** или **указанного** пользователя из Redmine.
- Авторизация через Redmine API token или логин/пароль.
- Группировка задач по проекту и версии.
- Перевод статусов задач на русский язык.
- Разбивка по типам активности (`--by-activity`).
- Вывод в консоль (таблица / компактный вид).
- Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
- Excel-отчёт с merge-ячейками по проекту/версии, итогами, автошириной, автофильтром и закреплённой шапкой.
- Сводка по времени (`--summary`).
- YAML-конфиг (`~/.config/redmine-reporter/config.yml`): шаблон имени файла, путь по умолчанию, период, email, настройки содержимого отчёта (`report.no_time`).
- Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание.
- `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска.
- `--send`: отправка отчёта по email через SMTP сразу после генерации.
- HTML-версия тела письма при `--send`, если включено в YAML-конфиге (`email.html: true`).
- Понятные сообщения об ошибках Redmine API, SMTP и файловой системы.
- Загрузка альтернативного `.env` через `--config`.
- Отчёт по time entries текущего или указанного пользователя (`--user-id`, `--user-login`, `--user-name`).
- Группировка задач по проекту и версии, перевод статусов на русский язык.
- Вывод в консоль (таблица или компактный вид) и экспорт в ODT, CSV, Markdown, HTML, JSON, XLSX.
- Разбивка времени по типам активности (`--by-activity`), сводка (`--summary`), скрытие времени (`--no-time`).
- Гибкий выбор периода: `--date`, переменные окружения, YAML-конфиг, по умолчанию — текущий месяц.
- `--commit`: сохранение отчёта в файл и фиксация периода в конфиге для следующего запуска.
- `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо).
- YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md).
## Установка
Требуется Python >= 3.10.
```bash
git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git
cd redmine-reporter
@@ -42,260 +43,40 @@ pip install .
pip install -e ".[dev]"
```
## Настройка
Источники конфигурации (от высшего приоритета к низшему):
```
CLI-флаги > переменные окружения > .env > YAML-конфиг > кодовые дефолты
```
### YAML-конфиг (основной способ)
## Быстрый старт
```bash
# Сгенерировать YAML из текущего .env
# Сгенерировать конфиг ~/.config/redmine-reporter/config.yml
redmine-reporter --init-config
# Редактировать под себя
# Заполнить redmine.url, redmine.api_key (или ${REDMINE_API_KEY}), redmine.author
vim ~/.config/redmine-reporter/config.yml
```
Структура:
```yaml
redmine:
url: https://red.eltex.loc
api_key: ${REDMINE_API_KEY}
author: "Кокос А.А."
verify_ssl: true
period:
precision: date # date | datetime
default_from: "2026-06-01"
default_to: "2026-06-30"
dynamic: false
# last_used заполняется --commit (см. docs/CONFIG.md)
output:
dir: ~/reports
filename: "{author}_{from}_{to}.{ext}"
default_format: xlsx
report:
no_time: false
email:
html: false
smtp:
host: smtp.example.com
port: 587
user: bot@example.com
password: ${SMTP_PASSWORD}
tls: true
from: bot@example.com
to:
- boss@example.com
cc: []
bcc: []
subject: "Отчёт {author} за {period}"
body_text: "Во вложении отчёт."
attach: true
```
Шаблон `output.filename` поддерживает `{author}`, `{from}`, `{to}`, `{date}` (DD_MM_YYYY), `{ext}`.
Шаблоны `email.subject` и `email.body_text` поддерживают `{author}`, `{period}` (строка диапазона, например `2026-06-01--2026-06-30`).
Подробнее: [docs/CONFIG.md](docs/CONFIG.md).
### `.env` (legacy)
```ini
REDMINE_URL=https://red.eltex.loc/
REDMINE_API_KEY=ваш_api_token
REDMINE_AUTHOR=Иванов Иван Иванович
DEFAULT_FROM_DATE=2026-01-01
DEFAULT_TO_DATE=2026-01-31
```
Переменные окружения:
| Переменная | Обязательность | Описание |
| --- | --- | --- |
| `REDMINE_URL` | Да | URL Redmine. |
| `REDMINE_API_KEY` | Да, если нет логина и пароля | Redmine API token. |
| `REDMINE_USER` | Да, если нет токена | Логин Redmine. |
| `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. |
| `REDMINE_AUTHOR` | Нет | Имя автора для отчёта. |
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). |
| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию (`YYYY-MM-DD`). |
| `REDMINE_VERIFY` | Нет | TLS-проверка: `true` / `false` / путь к CA bundle. |
## Использование
```bash
source .venv/bin/activate
```
### Основные сценарии
Отчёт за период по умолчанию:
```bash
redmine-reporter
```
Произвольный период:
```bash
redmine-reporter --date 2026-02-01--2026-02-28
```
Другой пользователь:
```bash
redmine-reporter --user-id 42
redmine-reporter --user-login ivanov
redmine-reporter --user-name "Иванов И.И."
```
Переопределить URL / API-ключ:
```bash
redmine-reporter --url https://red.example.com --api-key ваш_токен
```
Альтернативный `.env`:
```bash
redmine-reporter --config /path/to/.env
```
Компактный / отладочный вывод:
```bash
redmine-reporter --compact
redmine-reporter --debug
```
### Экспорт в файл
Явный путь:
```bash
redmine-reporter --output report.xlsx
redmine-reporter --output /path/to/report.odt
```
Только формат (путь и имя берутся из YAML-шаблона):
```bash
redmine-reporter --output xlsx # → output.dir/отчёт_01_07_2026.xlsx
redmine-reporter --output odt # → output.dir/отчёт_01_07_2026.odt
```
Путь без расширения (дописывается `default_format` из конфига):
```bash
redmine-reporter --output /tmp/report # → /tmp/report.xlsx (если default_format: xlsx)
```
### Отправка по email (`--send`)
Отправить отчёт на email, указанный в YAML-конфиге (секция `email`):
```bash
# Сохранить по шаблону и отправить
redmine-reporter --date 2026-06-01--2026-06-30 --send
# С явным путём
redmine-reporter --date 2026-06-01--2026-06-30 --output ~/report.xlsx --send
# Вместе с фиксацией периода
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
```
Если в секции `email` установить `html: true`, письмо будет отправлено в двух версиях: plain-text и HTML (таблица отчёта прямо в теле письма). Файл отчёта всё равно прикрепляется, если `attach: true`.
```yaml
email:
html: true
```
Если секция `email` не настроена — ошибка с пояснением. При ошибке SMTP файл отчёта остаётся на диске, данные не теряются. Поддерживаются `to`, `cc`, `bcc`, TLS, отключение вложения (`attach: false`).
### Фиксация периода (`--commit`)
```bash
# Сгенерировать, сохранить в файл по шаблону, запомнить период
redmine-reporter --commit
# С явным путём
redmine-reporter --commit --output report.xlsx
# Следующий запуск (без --date) возьмёт следующий период автоматически
# Отчёт за текущий месяц в консоль
redmine-reporter
# При precision=datetime запоминает момент времени
# (предотвращает дублирование записей внутри дня)
# Сохранить в файл и зафиксировать период для следующего запуска
redmine-reporter --commit
```
### Сводка и опции
## Документация
Без времени / с разбивкой по активностям:
```bash
redmine-reporter --no-time
redmine-reporter --by-activity
redmine-reporter --by-activity --summary
```
`--no-time` можно задать в YAML-конфиге (`report.no_time: true`), чтобы автоматические режимы (`--commit`, `--send`) не включали затраченное время без явного флага. При ручном `--output` YAML-значение не применяется — только CLI-флаг `--no-time`.
Сводка:
```bash
redmine-reporter --summary
```
- [docs/USER_GUIDE.md](docs/USER_GUIDE.md) — руководство пользователя: сценарии использования, справочник всех CLI-флагов, устранение неполадок.
- [docs/CONFIG.md](docs/CONFIG.md) — справочник конфигурации: YAML-структура, переменные окружения, приоритеты, безопасность.
## Форматы вывода
| Формат | Особенности |
| --- | --- |
| **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. |
| Консоль | Таблица или компактный вид (`--compact`). |
| ODT | Требуется `odfpy`; формирование по шаблону. |
| CSV | UTF-8 с BOM; полные значения `project`/`version` в каждой строке. |
| Markdown | Компактная таблица. |
| HTML | Полный HTML-документ; объединение ячеек групп через rowspan. |
| JSON | Объекты `project`, `version`, `issue_id`, `subject`, `status`, `time` + опционально `activities`. |
| XLSX | Объединение ячеек по проекту/версии, итоги, автоширина (максимум 80), автофильтр, freeze panes. |
## Полный список флагов
```
--date DATE Диапазон дат: YYYY-MM-DD--YYYY-MM-DD
--compact Компактный текстовый вывод вместо таблицы
--output PATH/FMT Путь к файлу (.odt/.csv/.md/.html/.json/.xlsx)
или bare-формат (xlsx/odt/...) — путь из конфига
--author NAME Переопределить имя автора
--no-time Не включать затраченное время в таблицу
--url URL Переопределить Redmine URL
--api-key KEY Переопределить Redmine API key
--config PATH Путь к альтернативному .env-файлу
--verbose Подробный вывод
--debug Отладочный вывод
--version Показать версию и выйти
--summary Вывести сводку по времени в stderr
--user-id ID Redmine ID пользователя для отчёта
--user-login LOGIN Логин пользователя Redmine
--user-name NAME Полное имя пользователя Redmine
--by-activity Разбить время по типам активности
--init-config Сгенерировать YAML-конфиг и выйти
--force Перезаписать существующий конфиг (с --init-config)
--config-path PATH Путь к YAML-конфигу (по умолчанию ~/.config/redmine-reporter/config.yml)
--commit Сохранить отчёт в файл и зафиксировать период в конфиге
--send Отправить отчёт по email после сохранения
```
Нюанс `--no-time`: физически удаляет колонку времени только CSV; в XLSX колонки остаются, но пустыми и без итогов; в остальных форматах — пустые значения.
## Разработка
@@ -303,15 +84,23 @@ redmine-reporter --summary
```bash
pytest
isort --check-only redmine_reporter tests
black --check redmine_reporter tests
ruff check redmine_reporter tests
ruff format --check redmine_reporter tests
mypy redmine_reporter
```
CI — Gitea Actions (`.gitea/workflows/checks.yaml`): все шесть проверок на матрице Python 3.103.13.
## Безопасность
- Не коммитьте `.env`, API token, пароль или логин.
- YAML-конфиг имеет права `0600`, директория — `0700`.
- Рекомендуется хранить секреты через `${VAR}`, а не plaintext.
- Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач.
- Инструмент работает только в режиме чтения и не изменяет данные в Redmine.
- `REDMINE_URL` обязан использовать HTTPS: валидация отклоняет остальное, API-ключ передаётся в заголовках.
- `verify_ssl` / `REDMINE_VERIFY`: `true` (по умолчанию — проверка по системному хранилищу CA операционной системы через truststore, корпоративные CA из ОС работают), `false` (предупреждение о MITM-риске при старте) или путь к CA-bundle.
- Конфиг создаётся с правами `0600`, директория — `0700`; при более широких правах выводится предупреждение.
- Секреты храните через `${VAR}` в YAML или в переменных окружения, не в открытом виде.
- Инструмент только читает данные из Redmine и ничего в нём не изменяет.
## Лицензия
MIT, см. [LICENSE](LICENSE).

View File

@@ -1,5 +1,33 @@
# Настройка redmine-reporter
## Оглавление
- [Источники конфигурации](#источники-конфигурации)
- [YAML-конфиг](#yaml-конфиг)
- [Структура](#структура)
- [`redmine.verify_ssl` — проверка TLS-сертификата](#redmineverify_ssl--проверка-tls-сертификата)
- [`period.precision` — точность периода](#periodprecision--точность-периода)
- [`period.default_to` — необязательное окончание периода](#perioddefault_to--необязательное-окончание-периода)
- [Период по умолчанию — текущий месяц](#период-по-умолчанию--текущий-месяц)
- [`--date` — формат диапазона](#--date--формат-диапазона)
- [`--commit` — автофиксация периода](#--commit--автофиксация-периода)
- [`email` — настройка отправки по почте](#email--настройка-отправки-по-почте)
- [`--send` — отправка отчёта по email](#--send--отправка-отчёта-по-email)
- [`output` — путь и имя файла по умолчанию](#output--путь-и-имя-файла-по-умолчанию)
- [Подстановка переменных окружения](#подстановка-переменных-окружения)
- [`report` — настройки содержимого отчёта](#report--настройки-содержимого-отчёта)
- [`report.status_translation` — перевод статусов](#reportstatus_translation--перевод-статусов)
- [Разрешение выходного пути](#разрешение-выходного-пути)
- [Миграция с `.env` на YAML](#миграция-с-env-на-yaml)
- [Быстрый старт](#быстрый-старт)
- [Что делает `--init-config`](#что-делает---init-config)
- [Флаги миграции](#флаги-миграции)
- [Проверка после миграции](#проверка-после-миграции)
- [Сосуществование `.env` и YAML](#сосуществование-env-и-yaml)
- [Откат](#откат)
- [`.env` (legacy)](#env-legacy)
- [Безопасность](#безопасность)
## Источники конфигурации
Приоритет, от высшего к низшему:
@@ -8,6 +36,9 @@
CLI-флаги > переменные окружения > .env > YAML > кодовые дефолты
```
Нюанс с `override` при автозагрузке `.env` и `--config` — см.
[Сосуществование `.env` и YAML](#сосуществование-env-и-yaml).
Если значение не задано на верхнем уровне, берётся уровень ниже. `.env` и YAML
работают одновременно — можно оставить оба, можно удалить `.env` после миграции.
@@ -32,6 +63,7 @@ redmine:
period:
precision: date
default_from: "2026-06-01"
# default_to можно не указывать — тогда конец периода будет сегодня
default_to: "2026-06-30"
dynamic: false
last_used:
@@ -64,15 +96,71 @@ email:
attach: true
```
### `redmine.verify_ssl` — проверка TLS-сертификата
Управляет проверкой TLS-сертификата Redmine. Семантика едина с переменной
окружения `REDMINE_VERIFY`:
| Значение | Поведение |
|---|---|
| `true` (по умолчанию) | Проверка по системному хранилищу CA операционной системы (через truststore) — корпоративные CA, добавленные в ОС, работают без настройки |
| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM |
| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть |
До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь
`/etc/ssl/certs/ca-certificates.crt`, который существует только в
Debian/Ubuntu. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
одинаково — оба включают проверку по системному хранилищу CA на любой ОС
(через truststore), без привязки к конкретному пути.
### `period.precision` — точность периода
Определяет, как вычисляется следующий период после фиксации:
- `date` (по умолчанию) — период с точностью до дня. Следующий запуск (после `--commit`) начинается со следующего дня.
- `datetime` — период с точностью до секунды. При повторном запуске time entries с `created_on` и `updated_on` ранее `last_used.to` исключаются (AND-логика: запись исключается только если **оба** поля раньше cutoff). Это предотвращает дублирование при отправке отчёта внутри рабочего дня.
- `datetime` — период с точностью до секунды. При повторном запуске time entries, у которых `created_on` или `updated_on` раньше `last_used.to`, исключаются: запись сохраняется, только если **оба** поля не раньше cutoff (AND-логика). Записи без дат (оба поля отсутствуют) сохраняются; если задано только одно поле, проверяется оно. Это предотвращает дублирование при отправке отчёта внутри рабочего дня. Cutoff применяется всегда, когда вычислен (`precision: datetime` и задан `last_used.to`) — в том числе при явном `--date`.
`last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется.
### `period.default_to` — необязательное окончание периода
Если `period.default_to` не задан, а `period.default_from` задан, инструмент
использует сегодняшнюю дату в качестве конца периода.
```yaml
period:
default_from: "2026-07-01"
# default_to отсутствует → конец периода = сегодня
```
Это предотвращает устаревание периода, когда отчёт генерируется автоматически
(`--send`, `--commit`) без явного `--date`.
Аналогично работает `DEFAULT_TO_DATE`: если переменная не задана, а
`DEFAULT_FROM_DATE` задана, конец периода = сегодня.
Обратное не работает: `period.default_to` без `period.default_from`
`DEFAULT_TO_DATE` без `DEFAULT_FROM_DATE`) игнорируется — период определяется
остальными источниками.
### Период по умолчанию — текущий месяц
Если период не задан ни одним из источников (`--date`, `DEFAULT_FROM_DATE`,
`period.default_from`), отчёт строится за текущий месяц: начало периода —
1-е число текущего месяца, конец — сегодняшняя дата. Период вычисляется
на момент запуска и не хранится в коде или конфиге.
### `--date` — формат диапазона
Флаг `--date` принимает два формата:
- Даты: `YYYY-MM-DD--YYYY-MM-DD` (например, `2026-06-01--2026-06-30`).
- Datetime с секундами: `YYYY-MM-DDTHH:MM:SS--YYYY-MM-DDTHH:MM:SS`
(например, `2026-06-30T09:00:00--2026-06-30T12:00:00`).
Обе границы должны быть в одном формате — смешанная точность отвергается.
Начало позже конца (`start > end`) — ошибка.
### `--commit` — автофиксация периода
Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода.
@@ -94,6 +182,9 @@ email:
- Произвольный диапазон → та же длительность, начиная со дня после `last_used.to`.
- `dynamic: false``--commit` перезаписывает `default_from`/`default_to` на использованный период.
**Нюанс:** при `--commit` YAML-файл перезаписывается целиком через `yaml.dump`
пользовательские комментарии и ручное форматирование в файле теряются.
**Примеры:**
```bash
@@ -187,7 +278,8 @@ email:
3. Формирует MIME-письмо:
- Тема, plain-text тело и вложение (если `attach: true`).
- При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта.
4. Отправляет через SMTP с TLS (таймаут 30 секунд).
4. Отправляет через SMTP (таймаут 30 секунд). STARTTLS включается только при
`email.smtp.tls: true`; аутентификация — только если задан `email.smtp.user`.
**Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся
на диске, данные не теряются.
@@ -289,6 +381,7 @@ email:
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта |
| `status_translation` | map[str,str] | `{}` | Переопределение/дополнение перевода статусов |
`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`).
При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`.
@@ -312,6 +405,22 @@ redmine-reporter --output report.odt
redmine-reporter --output report.odt --no-time
```
### `report.status_translation` — перевод статусов
По умолчанию статусы Redmine переводятся встроенным словарём
(`New``В работе`, `Closed``Закрыто` и т.д.). Секция
`report.status_translation` переопределяет отдельные переводы и/или
добавляет новые статусы; не указанные здесь статусы переводятся
встроенным словарём, а совсем неизвестные выводятся как есть.
```yaml
report:
status_translation:
"New": "Новая"
"Wait Release": "Ожидает релиза"
"Custom Status": "Кастомный статус"
```
## Разрешение выходного пути
Функция `resolve_output_path()` определяет итоговый путь к файлу:
@@ -340,7 +449,8 @@ vim ~/.config/redmine-reporter/config.yml
### Что делает `--init-config`
- Читает текущие значения из `.env` и переменных окружения.
- Формирует YAML со всеми секциями (`redmine`, `period`, `output`, `email`).
- Формирует YAML со всеми секциями (`redmine`, `period`, `output`, `report`, `email`).
- В конец файла дописывает закомментированный пример `report.status_translation`.
- Секреты (`REDMINE_API_KEY`, `SMTP_PASSWORD`) записывает как `${VAR}`, если
переменная существует, иначе — пустая строка.
- Создаёт файл с правами `0600`, директорию — с `0700`.
@@ -351,7 +461,11 @@ vim ~/.config/redmine-reporter/config.yml
|---|---|
| `--init-config` | Создать YAML и выйти |
| `--init-config --force` | Перезаписать существующий YAML |
| `--config-path PATH` | Сохранить YAML по указанному пути (по умолчанию `~/.config/redmine-reporter/config.yml`) |
| `--config-path PATH` | Путь к YAML-конфигу: загрузка при запуске, запись при `--init-config` и `--commit` (по умолчанию `~/.config/redmine-reporter/config.yml`) |
Если `DEFAULT_TO_DATE` не задана, а `DEFAULT_FROM_DATE` задана, сгенерированный
YAML будет содержать пустое `default_to`, и при запуске инструмент использует
сегодняшнюю дату.
### Проверка после миграции
@@ -379,6 +493,10 @@ ls -la ~/.config/redmine-reporter/
YAML работает как базовый слой для всего, что не в .env
```
Автозагрузка `.env` (без `--config`) не перебивает реальные переменные
окружения (`override=False`). Исключение — `--config FILE`: указанный `.env`
загружается с `override=True` и перебивает переменные окружения (но не CLI-флаги).
Это safe — если с YAML что-то пойдёт не так, просто положи `.env` обратно.
### Откат
@@ -400,7 +518,7 @@ DEFAULT_TO_DATE=2026-06-30
```
Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц
как период, стандартный путь сертификатов, пустой автор).
как период, стандартная проверка TLS, пустой автор).
## Безопасность

304
docs/USER_GUIDE.md Normal file
View File

@@ -0,0 +1,304 @@
# Руководство пользователя redmine-reporter
## Оглавление
- [Установка](#установка)
- [Первоначальная настройка](#первоначальная-настройка)
- [Выбор периода](#выбор-периода)
- [Отчёт за другого пользователя](#отчёт-за-другого-пользователя)
- [Экспорт в файл](#экспорт-в-файл)
- [Содержимое отчёта](#содержимое-отчёта)
- [Отправка по email](#отправка-по-email)
- [Ежемесячный цикл с --commit](#ежемесячный-цикл-с---commit)
- [Справочник флагов](#справочник-флагов)
- [Устранение неполадок](#устранение-неполадок)
- [См. также](#см-также)
## Установка
Требуется Python >= 3.10.
```bash
git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git
cd redmine-reporter
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install .
```
Проверка:
```bash
redmine-reporter --version
```
Перед каждым запуском активируйте окружение: `source .venv/bin/activate`.
## Первоначальная настройка
1. Убедитесь, что в текущей директории есть `.env` с `REDMINE_URL` и `REDMINE_API_KEY` (или задайте эти переменные в окружении).
2. Сгенерируйте YAML-конфиг:
```bash
redmine-reporter --init-config
```
Результат: файл `~/.config/redmine-reporter/config.yml` (права `0600`, директория `0700`) со всеми секциями и закомментированным примером `report.status_translation`. Секреты, заданные в окружении, записываются как `${VAR}`.
3. Если файл уже существует — ошибка; для перезаписи добавьте `--force`:
```bash
redmine-reporter --init-config --force
```
4. Нестандартное расположение конфига:
```bash
redmine-reporter --init-config --config-path /etc/redmine-reporter/config.yml
```
`--config-path` задаёт путь и для загрузки YAML, и для записи при `--init-config` и `--commit`.
5. Откройте файл и заполните минимум: `redmine.url`, `redmine.api_key`, `redmine.author`.
`--init-config` несовместим с флагами отчёта (`--date`, `--output`, `--compact`, `--summary`, `--user-id`/`--user-login`/`--user-name`, `--no-time`, `--by-activity`, `--send`) — указывайте его отдельно.
Полный референс всех секций и переменных: [CONFIG.md](CONFIG.md).
## Выбор периода
Источник периода выбирается по приоритету (от высшего к низшему):
1. `--date` — явный диапазон.
2. `DEFAULT_FROM_DATE` (env/.env) — конец: `DEFAULT_TO_DATE` или сегодня. `DEFAULT_TO_DATE` без `DEFAULT_FROM_DATE` игнорируется.
3. `period.dynamic: true` + `period.last_used` в YAML — следующий период после `last_used`:
- последний период — полный календарный месяц → следующий полный месяц;
- произвольный диапазон → та же длительность, начиная со дня после `last_used.to`;
- при `precision: datetime` → та же длительность, сдвинутая на 1 секунду после `last_used.to`.
4. `period.default_from` в YAML — конец: `default_to` или сегодня. `default_to` без `default_from` игнорируется.
5. Ничего не задано → текущий месяц: с 1-го числа по сегодня.
Форматы `--date`:
```bash
# Даты
redmine-reporter --date 2026-06-01--2026-06-30
# Дата-время
redmine-reporter --date 2026-06-01T09:00:00--2026-06-01T18:00:00
```
Смешанная точность (дата + дата-время в одном диапазоне) и диапазон, где начало позже конца, отвергаются с ошибкой.
## Отчёт за другого пользователя
Без флагов отчёт строится за текущего пользователя.
```bash
redmine-reporter --user-id 42
redmine-reporter --user-login ivanov
redmine-reporter --user-name "Иванов Иван Иванович"
```
Правила:
- Флаги `--user-id`, `--user-login`, `--user-name` взаимоисключающие: укажите только один, иначе ошибка.
- Все три принимают одно значение. Число или строка из цифр → поиск по ID. Иначе — точное регистрозависимое совпадение логина.
- Если по логину совпадений нет — поиск по имени. Одно совпадение → пользователь найден.
- Несколько совпадений по имени → ошибка «Multiple users match...» со списком логинов (при дублях логина — ID).
- Ноль совпадений → ошибка «User '...' not found...».
## Экспорт в файл
Флаг `--output` определяется по четырём правилам:
| Аргумент | Результат |
| --- | --- |
| `--output xlsx` (bare-формат: `xlsx`, `odt`, `csv`, `md`, `html`, `json`, регистронезависимо) | Путь = `output.dir` + шаблон `output.filename` из YAML, расширение = указанный формат |
| `--output /tmp/report` (путь без расширения) | Дописывается `.` + `output.default_format``/tmp/report.xlsx` |
| `--output /tmp/report.csv` (путь с любым расширением) | Используется как есть |
| `--output /tmp/report.xyz` (неизвестное расширение) | Ошибка «Неизвестный формат файла: '.xyz'. Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx», exit 1 |
Формат `.odt` требует установленного пакета `odfpy` (устанавливается по умолчанию) — иначе отдельная ошибка.
Примеры:
```bash
redmine-reporter --output xlsx # по шаблону в output.dir
redmine-reporter --output 2026-06-report # → 2026-06-report.xlsx (если default_format: xlsx)
redmine-reporter --output reports/june.csv # как есть
```
Плейсхолдеры `output.filename`:
| Плейсхолдер | Описание | Пример |
| --- | --- | --- |
| `{author}` | Имя автора, пробелы → `_` | `Кокос_А.А.` |
| `{from}` | Начало периода, `YYYY-MM-DD` | `2026-06-01` |
| `{to}` | Конец периода, `YYYY-MM-DD` | `2026-06-30` |
| `{date}` | Конец периода, `DD_MM_YYYY` | `30_06_2026` |
| `{ext}` | Расширение без точки | `xlsx` |
Неизвестные плейсхолдеры остаются в имени как есть.
Нюансы с путями:
- `~` в явном `--output` раскрывает шелл, а не программа: `redmine-reporter --output ~/report.xlsx` работает, а в кавычках (`"~/report.xlsx"`) — нет.
- `expanduser` применяется только к `output.dir` из YAML.
## Содержимое отчёта
### `--no-time`
Убирает затраченное время из отчёта. Физически удаляет колонку времени только CSV. XLSX, HTML, ODT, Markdown, JSON и консольный вывод оставляют пустые колонки/поля; в XLSX дополнительно пропадают строки итогов по версиям/проектам/всего.
```bash
redmine-reporter --output report.xlsx --no-time
```
YAML-настройка `report.no_time` действует только в автоматических режимах (`--commit`, `--send`). При ручном `--output` работает только CLI-флаг.
### `--by-activity`
Разбивка затраченного времени по типам активности. В JSON-отчёте добавляется поле `activities`.
```bash
redmine-reporter --by-activity
```
### `--summary`
Печатает сводку в stderr (после отчёта):
- `total` — суммарное время;
- `project:*` — время по каждому проекту;
- `activity:*` — время по каждой активности (только при одновременном `--by-activity`).
Версии в сводку не входят.
```bash
redmine-reporter --by-activity --summary
```
### `--compact`
Компактный текстовый вывод в консоль вместо таблицы:
```bash
redmine-reporter --compact
```
### Перевод статусов
Статусы задач переводятся встроенным словарём; секция `report.status_translation` переопределяет или дополняет переводы. Подробно: [CONFIG.md](CONFIG.md).
## Отправка по email
Требуется секция `email` в YAML-конфиге (см. [CONFIG.md](CONFIG.md)).
```bash
redmine-reporter --date 2026-06-01--2026-06-30 --send
redmine-reporter --date 2026-06-01--2026-06-30 --output report.xlsx --send
redmine-reporter --send --commit
```
Факты:
- Файл отчёта сохраняется на диск ДО отправки: при ошибке SMTP файл остаётся на месте.
- Секция `email` отсутствует или `smtp.host` пуст → ошибка «Email не настроен. Добавьте секцию 'email' в конфиг...», exit 1.
- `email.html: true` → письмо `multipart/alternative`: plain-text + HTML-таблица отчёта в теле.
- Файл прикрепляется при `attach: true`; MIME-тип определяется по расширению, неизвестное → `application/octet-stream`.
- Получатели `bcc` указываются только в envelope (не видны в заголовках письма).
- STARTTLS применяется только при `smtp.tls: true`; аутентификация на SMTP-сервере — только если задан `smtp.user`.
- Таймаут соединения с SMTP — 30 секунд.
Точные тексты ошибок:
- «Ошибка аутентификации SMTP. Проверьте логин и пароль.»
- «Таймаут соединения с SMTP-сервером.»
- «Ошибка отправки письма: ...»
- «Не удалось подключиться к SMTP-серверу host:port»
## Ежемесячный цикл с --commit
`--commit` сохраняет отчёт в файл (по `--output` или по шаблону `output.dir`/`output.filename`) и записывает `period.last_used` в YAML-конфиг.
Что пишется:
- `precision: datetime``last_used.from` = `last_used.to` = текущий момент UTC.
- `precision: date` → фактические `from`/`to` использованного периода.
- `dynamic: true``default_from`/`default_to` не меняются.
- `dynamic: false` → дополнительно перезаписываются `default_from`/`default_to`.
Типичный цикл:
```bash
# 1. Отчёт за июнь 2026: сохранить файл, зафиксировать период
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
# 2. Следующий запуск без --date возьмёт июль 2026 автоматически
# (при dynamic: true и полном месяце)
redmine-reporter --send --commit
```
Защита от дублей при `precision: datetime`:
- Если в YAML есть `last_used.to` (cutoff), time entry сохраняется в отчёт, только если `created_on` И `updated_on` оба >= cutoff; если хотя бы одно поле раньше cutoff — запись исключается.
- Записи, у которых оба поля (`created_on`, `updated_on`) отсутствуют, сохраняются; если задано только одно поле, решает оно.
- Фильтр применяется всегда при `precision: datetime`, в том числе при явном `--date`.
Важно: `--commit` перезаписывает YAML через `yaml.dump` — пользовательские комментарии в файле теряются.
## Справочник флагов
| Флаг | Описание |
| --- | --- |
| `--date` | Диапазон периода: `YYYY-MM-DD--YYYY-MM-DD` или `YYYY-MM-DDTHH:MM:SS--YYYY-MM-DDTHH:MM:SS`. Смешанная точность и `start > end` отвергаются. |
| `--compact` | Компактный текстовый вывод в консоль вместо таблицы. |
| `--output` | Путь к файлу (`.odt`/`.csv`/`.md`/`.html`/`.json`/`.xlsx`) или bare-формат — тогда путь берётся из YAML-шаблона; без флага — вывод в консоль (stdout). |
| `--author` | Переопределить имя автора отчёта. |
| `--no-time` | Не включать затраченное время в отчёт. |
| `--url` | Переопределить URL Redmine. |
| `--api-key` | Переопределить API-ключ Redmine. |
| `--config` | Путь к альтернативному `.env`-файлу. |
| `--verbose` | Подробный вывод; показывает traceback при ошибках. |
| `--debug` | Отладочный вывод; показывает traceback при ошибках. |
| `--version` | Показать версию и выйти. |
| `--summary` | Сводка по времени в stderr: `total`, `project:*`, `activity:*` (версии не печатаются). |
| `--user-id` | Отчёт за пользователя по ID. |
| `--user-login` | Отчёт за пользователя по логину (точное регистрозависимое совпадение). |
| `--user-name` | Отчёт за пользователя по имени. |
| `--by-activity` | Разбивка времени по типам активности (в JSON — поле `activities`). |
| `--init-config` | Сгенерировать YAML-конфиг из текущего `.env`/окружения и выйти. |
| `--force` | Перезаписать существующий YAML-конфиг (только с `--init-config`). |
| `--config-path` | Путь к YAML-конфигу: загрузка, запись `--init-config` и `--commit`. По умолчанию `~/.config/redmine-reporter/config.yml`. |
| `--commit` | Сохранить отчёт в файл и зафиксировать период (`period.last_used`) в YAML. |
| `--send` | Отправить отчёт по email после сохранения файла. |
## Устранение неполадок
| Ошибка | Причина | Решение |
| --- | --- | --- |
| «REDMINE_URL must use HTTPS...» (exit 1) | URL Redmine не по HTTPS | Укажите `https://` в `redmine.url` / `REDMINE_URL` / `--url`. |
| «Authentication failed: invalid API key, login or password. Check REDMINE_API_KEY / REDMINE_USER / REDMINE_PASSWORD.» | Неверный или отсутствующий API-ключ, логин или пароль | Проверьте `redmine.api_key` / `--api-key` (или `REDMINE_USER` / `REDMINE_PASSWORD`). |
| «User '...' not found...» | Пользователь не найден ни по ID, ни по логину, ни по имени | Проверьте написание; логин сравнивается точно с учётом регистра. |
| «Multiple users match...» | По имени найдено несколько пользователей (или несколько дублей логина) | Используйте `--user-login` с логином из списка или уточните числовой ID для `--user-id`. |
| Указано несколько `--user-*` | Флаги взаимоисключающие | Оставьте только один из `--user-id` / `--user-login` / `--user-name`. |
| «Неизвестный формат файла: '.xyz'. Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx» (exit 1) | `--output` с неизвестным расширением | Используйте одно из поддерживаемых расширений. |
| Ошибка про `odfpy` при `.odt` | Пакет `odfpy` не установлен | `pip install odfpy` или выберите другой формат. |
| «Email не настроен. Добавьте секцию 'email' в конфиг...» (exit 1) | `--send` без секции `email` в YAML или с пустым `smtp.host` | Добавьте секцию `email` (см. [CONFIG.md](CONFIG.md)). |
| «Ошибка аутентификации SMTP. Проверьте логин и пароль.» | Неверные `smtp.user`/`smtp.password` | Проверьте логин и пароль в конфиге. |
| «Таймаут соединения с SMTP-сервером.» | Сервер не ответил за 30 секунд | Проверьте сеть, `smtp.host` и `smtp.port`. |
| «Не удалось подключиться к SMTP-серверу host:port» | Нет соединения с SMTP | Проверьте `smtp.host`, `smtp.port` и доступность сервера. |
| «Ошибка отправки письма: ...» | Прочая ошибка SMTP | Смотрите детали после двоеточия; файл отчёта уже сохранён на диске. |
| Предупреждение о правах конфига при старте | Права `config.yml` шире `0600` | `chmod 600 ~/.config/redmine-reporter/config.yml`. |
| Предупреждение о MITM при старте | `verify_ssl: false` / `REDMINE_VERIFY=false` — проверка TLS отключена | Включите проверку или используйте только в доверенной сети. |
| Не виден traceback при ошибке | Traceback показывается только с `--verbose`/`--debug` | Повторите запуск с `--debug`. |
API-ключ в текстах ошибок маскируется (`***`).
## См. также
- [README.md](../README.md) — обзор, быстрый старт, форматы вывода.
- [CONFIG.md](CONFIG.md) — полный референс конфигурации (YAML, env, приоритеты, безопасность).

View File

@@ -4,20 +4,19 @@ build-backend = "setuptools.build_meta"
[project]
name = "redmine-reporter"
version = "1.10.0"
version = "1.11.1"
description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
license = { text = "MIT" }
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Operating System :: POSIX :: Linux",
"Environment :: Console",
]
requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = [
"python-redmine>=2.4.0",
"tabulate>=0.9.0",
@@ -25,6 +24,9 @@ dependencies = [
"odfpy>=1.4.0",
"openpyxl>=3.1.0",
"pyyaml>=6.0",
"requests>=2.31",
"truststore>=0.10",
"urllib3>=1.26",
]
[project.optional-dependencies]
@@ -32,6 +34,9 @@ dev = [
"pytest>=7.0",
"mypy>=1.0",
"ruff>=0.1.0",
"black>=24.0",
"isort>=5.0",
"setuptools>=61.0",
]
[project.scripts]
@@ -44,9 +49,16 @@ include = ["redmine_reporter*"]
[tool.setuptools.package-data]
"redmine_reporter" = ["templates/template.odt"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.mypy]
warn_unused_configs = true
[[tool.mypy.overrides]]
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"]
ignore_missing_imports = true
[tool.isort]
profile = "black"

View File

@@ -1 +1 @@
__version__ = "1.10.0"
__version__ = "1.11.1"

View File

@@ -8,6 +8,7 @@ from pathlib import Path
from typing import List, Optional
import yaml
from dotenv import find_dotenv, load_dotenv
from . import __version__
from .client import RedmineAPIError, fetch_issues_with_spent_time
@@ -17,6 +18,26 @@ from .mailer import send_report
from .report_builder import build_grouped_report, calculate_summary
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
# Маскируем API-ключ Redmine в тексте ошибок (#55):
# - query-параметр key=<значение> в URL (?key=... / &key=...);
# - заголовок X-Redmine-API-Key: <значение>.
_SANITIZE_PATTERNS = [
(re.compile(r"([?&]key=)[^\s&\"')]+", re.IGNORECASE), r"\1***"),
(
re.compile(
r"(X-Redmine-API-Key[\"']?\s*[:=]\s*[\"']?)[^\s\"',}]+", re.IGNORECASE
),
r"\1***",
),
]
def _sanitize_error_text(text: str) -> str:
"""Маскирует API-ключ Redmine в тексте ошибки перед выводом в stderr (#55)."""
for pattern, replacement in _SANITIZE_PATTERNS:
text = pattern.sub(replacement, text)
return text
def parse_date_range(date_arg: str) -> tuple[str, str]:
if "--" not in date_arg:
@@ -25,25 +46,41 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
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
datetime_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
if re.fullmatch(date_pattern, from_date) and re.fullmatch(date_pattern, to_date):
fmt = "%Y-%m-%d"
elif re.fullmatch(datetime_pattern, from_date) and re.fullmatch(
datetime_pattern, to_date
):
fmt = "%Y-%m-%dT%H:%M:%S"
else:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try:
start = datetime.strptime(from_date, "%Y-%m-%d").date()
end = datetime.strptime(to_date, "%Y-%m-%d").date()
start = datetime.strptime(from_date, fmt)
end = datetime.strptime(to_date, fmt)
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
if start > end:
raise ValueError("Date range start must be less than or equal to end")
if fmt == "%Y-%m-%d":
return start.date().isoformat(), end.date().isoformat()
return start.isoformat(), end.isoformat()
def _run_init_config(config_path: str, force: bool) -> int:
"""Создаёт YAML-конфиг из текущих настроек окружения."""
"""Создаёт YAML-конфиг из текущих настроек окружения.
Подгружает .env из текущей директории (override=False: реальные
переменные окружения не перебиваются), чтобы --init-config видел
значения, заданные только в .env (#64). Поиск идёт от cwd
(usecwd=True): find_dotenv() без него стартует от директории
cli.py и .env пользователя не находит.
"""
load_dotenv(find_dotenv(usecwd=True), override=False)
path = Path(config_path)
if path.exists() and not force:
@@ -99,6 +136,16 @@ def _run_init_config(config_path: str, force: bool) -> int:
yaml.dump(
data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
)
fh.write(
"\n"
"# report.status_translation: переопределение/дополнение перевода\n"
"# статусов Redmine. Без секции используется встроенный словарь.\n"
"# Пример:\n"
"# report:\n"
"# status_translation:\n"
'# "New": "Новая"\n'
'# "Wait Release": "Ожидает релиза"\n'
)
path.chmod(0o600)
sections_found = [s for s in data if data[s]]
@@ -219,7 +266,7 @@ def _save_and_maybe_send(
)
print(f"📧 Report sent to {', '.join(email_config.to)}")
except RedmineAPIError as e:
print(f"{e.message}", file=sys.stderr)
print(f"{_sanitize_error_text(e.message)}", file=sys.stderr)
return 1
return 0
@@ -385,18 +432,16 @@ def main(argv: Optional[List[str]] = None) -> int:
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:
print(f"{_sanitize_error_text(e.message)}", file=sys.stderr)
if (args.verbose or args.debug) and e.original is not None:
logging.exception("Original Redmine API error")
return 1
except Exception as e:
print(f"❌ Unexpected error: {e}", file=sys.stderr)
print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
if args.verbose or args.debug:
logging.exception("Unexpected error")
return 1
if issue_hours is None:
print(" No time entries found in the given period.", file=sys.stderr)
return 0
if not issue_hours:
print(" No time entries found in the given period.", file=sys.stderr)
return 0
@@ -410,6 +455,7 @@ def main(argv: Optional[List[str]] = None) -> int:
issue_hours,
fill_time=not no_time,
by_activity=args.by_activity,
status_translation=Config.get_status_translation(),
)
if args.summary:
@@ -521,9 +567,9 @@ def main(argv: Optional[List[str]] = None) -> int:
dynamic = Config._app.period_dynamic if Config._app else False
if precision == "datetime":
from datetime import datetime as dt_mod
now = dt_mod.now().isoformat(timespec="seconds")
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
# метки aware cutoff для дедупликации.
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
from_str = now
to_str = now
else:

View File

@@ -1,7 +1,9 @@
from datetime import datetime
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union
import requests
import truststore
from redminelib import Redmine
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
from redminelib.resources import Issue
@@ -48,12 +50,21 @@ def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
def _create_redmine() -> Redmine:
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24)."""
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24).
При verify=True подключает системное хранилище CA ОС через
truststore.inject_into_ssl() (#62).
"""
verify = Config.get_redmine_verify()
if verify is True:
# verify_ssl: true — проверка по системному хранилищу CA ОС (truststore),
# а не по certifi: корпоративные CA из ОС продолжают работать (#62).
truststore.inject_into_ssl()
redmine = Redmine(
Config.get_redmine_url(),
**_get_redmine_auth_kwargs(),
requests={
"verify": Config.get_redmine_verify(),
"verify": verify,
"timeout": REQUEST_TIMEOUT,
},
)
@@ -121,7 +132,14 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
try:
activities = redmine.enumeration.filter(resource="time_entry_activities")
return {int(a.id): str(a.name) for a in activities}
except Exception:
except Exception as exc:
# #61: не глотаем сбой молча — предупреждаем, что разбивка по
# активностям будет построена по сырым данным из самих entries.
print(
f"⚠️ Could not load time entry activities: {exc}. "
"Activity names will be taken from time entries.",
file=sys.stderr,
)
return {}
@@ -150,20 +168,28 @@ def _parse_datetime(value: Any) -> Optional[datetime]:
Accepts datetime objects, ISO strings (with or without timezone),
or None. Returns a timezone-aware datetime or None.
Naive datetimes are treated as UTC (#58).
"""
if value is None:
return None
if isinstance(value, datetime):
return value
return _ensure_aware_utc(value)
if isinstance(value, str):
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return dt
return _ensure_aware_utc(dt)
except (ValueError, TypeError):
return None
return None
def _ensure_aware_utc(dt: datetime) -> datetime:
"""Возвращает aware datetime; naive трактуется как UTC (#58)."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = []
@@ -197,8 +223,18 @@ def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int:
# Затем ищем по логину
try:
users = redmine.user.filter(login=text)
if users:
return int(users[0].id)
# Фильтр Redmine по логину неточный (substring-поиск), поэтому
# выбираем только точные регистрозависимые совпадения логина (#60).
exact_matches = [u for u in users if getattr(u, "login", None) == text]
if len(exact_matches) == 1:
return int(exact_matches[0].id)
if len(exact_matches) > 1:
matches = ", ".join(str(u.id) for u in exact_matches[:5])
raise RedmineAPIError(
f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID."
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(
f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}",
@@ -249,8 +285,11 @@ def fetch_issues_with_spent_time(
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).
If dedup_before is set, entries with both fields set are kept only when
created_on AND updated_on are both >= dedup_before; an entry is excluded
if at least one of the fields is before the cutoff (dedup_before).
Entries with both fields missing (None) are kept;
if only one field is set, that field alone decides (>= cutoff keeps the entry).
Returns list of (issue, total_hours, activities) tuples.
Raises RedmineAPIError on API/auth/network failures.
"""
@@ -274,10 +313,17 @@ def fetch_issues_with_spent_time(
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте.
# Запись исключается, если BOTH created_on AND updated_on < dedup_before.
# Записи без метаданных (created_on/updated_on == None) не фильтруются.
# Если оба поля заданы, запись сохраняется, только если created_on И updated_on
# оба >= dedup_before; если хотя бы одно из полей < dedup_before,
# запись исключается.
# Если оба поля None — запись сохраняется; если задано только одно поле,
# решает оно (>= dedup_before → запись сохраняется).
if dedup_before is not None:
# Нормализуем cutoff к aware UTC (#58): naive cutoff трактуем как UTC,
# чтобы сравнение с нормализованными created_on/updated_on было корректным.
dedup_before = _ensure_aware_utc(dedup_before)
filtered: list = []
try:
for entry in time_entries:
created = _parse_datetime(getattr(entry, "created_on", None))
updated = _parse_datetime(getattr(entry, "updated_on", None))
@@ -291,6 +337,11 @@ def fetch_issues_with_spent_time(
filtered.append(entry)
elif updated is not None and updated >= dedup_before:
filtered.append(entry)
except TypeError as exc:
raise RedmineAPIError(
f"Failed to compare time entry dates with deduplication cutoff: {exc}",
original=exc,
) from exc
time_entries = filtered
# Агрегируем часы по issue.id (и активности, если требуется)
@@ -319,6 +370,20 @@ def fetch_issues_with_spent_time(
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# #61: задачи могли не вернуться из issue.filter (нет прав / удалены) —
# предупреждаем о выпавших задачах и потерянных часах, но строим отчёт
# по доступным данным.
returned_ids = {issue.id for issue in issues}
missing_ids = sorted(issue_ids - returned_ids)
if missing_ids:
lost_hours = sum(spent_time[iid] for iid in missing_ids)
print(
f"⚠️ {len(missing_ids)} issue(s) unavailable (no access or deleted; "
f"IDs: {', '.join(str(i) for i in missing_ids)}): "
f"{lost_hours:g}h excluded from the report.",
file=sys.stderr,
)
# Сопоставляем задачи с суммарным временем.
# Сортировка выполняется в report_builder.build_grouped_report,
# здесь оставляем порядок API как есть.

View File

@@ -1,20 +1,19 @@
import logging
import os
import sys
from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path
from typing import Union
from typing import Dict, Union
import yaml
from dotenv import load_dotenv
from .yaml_config import check_file_permissions, resolve_env_vars
load_dotenv(override=False)
logger = logging.getLogger(__name__)
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
DEFAULT_REDMINE_VERIFY: Union[bool, str] = True
FALSE_VALUES = {"0", "false", "no", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"}
@@ -63,6 +62,7 @@ class AppConfig:
output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx"
report_no_time: bool = False
report_status_translation: Dict[str, str] = field(default_factory=dict)
email: EmailConfig = field(default_factory=EmailConfig)
@classmethod
@@ -112,6 +112,9 @@ class AppConfig:
output_default_format=cls._resolve_str(raw, "output", "default_format")
or "xlsx",
report_no_time=cls._resolve_bool(raw, "report", "no_time"),
report_status_translation=cls._resolve_str_dict(
raw, "report", "status_translation"
),
email=cls._resolve_email(raw),
)
@@ -141,6 +144,43 @@ class AppConfig:
return ""
return str(value)
@staticmethod
def _resolve_str_dict(raw: dict, section: str, key: str) -> Dict[str, str]:
"""Читает вложенный mapping строк из YAML-секции.
Не-mapping значение логируется с warning и игнорируется.
Ключи и значения приводятся к str, ${VAR} в значениях резолвится.
Записи со значением null или не-scalar (dict/list) пропускаются
с warning — иначе null стал бы строкой "None", а dict — repr.
"""
section_value = raw.get(section)
if not isinstance(section_value, dict):
return {}
value = section_value.get(key)
if value is None:
return {}
if not isinstance(value, dict):
logger.warning(
"Config %s.%s must be a mapping, got %s — ignoring",
section,
key,
type(value).__name__,
)
return {}
result: Dict[str, str] = {}
for k, v in value.items():
if v is None or isinstance(v, (dict, list)):
logger.warning(
"Config %s.%s entry %r must be a scalar, got %s — skipping",
section,
key,
k,
type(v).__name__,
)
continue
result[str(k)] = resolve_env_vars(str(v))
return result
@staticmethod
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
value = raw.get(section, {}).get(key)
@@ -156,17 +196,23 @@ class AppConfig:
@classmethod
def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
"""Семантика verify_ssl, единая с REDMINE_VERIFY из env:
- true (bool или строка) → True — стандартная проверка TLS (requests);
- false (bool или строка) → False — проверка отключена;
- любая другая строка → путь к CA-bundle как есть.
"""
value = raw.get("redmine", {}).get("verify_ssl")
if value is None:
return DEFAULT_REDMINE_VERIFY
if isinstance(value, bool):
return DEFAULT_REDMINE_VERIFY if value else False
return value
if isinstance(value, str):
normalized = value.lower()
if normalized in FALSE_VALUES:
return False
if normalized in TRUE_VALUES:
return DEFAULT_REDMINE_VERIFY
return True
return resolve_env_vars(value)
return DEFAULT_REDMINE_VERIFY
@@ -242,7 +288,13 @@ class Config:
@classmethod
def load_yaml(cls, path: str) -> None:
"""Загружает YAML-конфиг. Не бросает исключений при отсутствии файла."""
"""Загружает конфигурацию приложения.
Сначала подгружает .env из текущей директории (override=False:
переменные окружения не перебиваются), затем читает YAML-конфиг.
Не бросает исключений при отсутствии файла.
"""
load_dotenv(override=False)
cls._app = AppConfig.from_yaml(path)
@classmethod
@@ -356,6 +408,18 @@ class Config:
return cls._app.report_no_time
return False
@classmethod
def get_status_translation(cls) -> Dict[str, str]:
"""Полный словарь перевода статусов: встроенный + переопределения из YAML.
Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется.
Импорт ленивый: config.py не должен тянуть redminelib при импорте.
"""
from .report_builder import STATUS_TRANSLATION
overrides = cls._app.report_status_translation if cls._app else {}
return {**STATUS_TRANSLATION, **overrides}
@classmethod
def get_email_config(cls) -> "EmailConfig | None":
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""
@@ -370,8 +434,11 @@ class Config:
def get_default_date_range(cls) -> str:
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}"
today = date.today()
today_str = today.isoformat()
if from_env:
return f"{from_env}--{to_env or today_str}"
if (
cls._app
@@ -386,22 +453,30 @@ class Config:
)
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}"
if cls._app and cls._app.period_default_from:
default_to = cls._app.period_default_to or today_str
return f"{cls._app.period_default_from}--{default_to}"
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()}"
return f"{start.isoformat()}--{today_str}"
@classmethod
def validate(cls) -> None:
if not cls.get_redmine_url():
url = cls.get_redmine_url()
if not url:
raise ValueError("REDMINE_URL is required (set via env or .env)")
if not url.lower().startswith("https://"):
raise ValueError(
"REDMINE_URL must use HTTPS: the API key is sent in request "
"headers and requires TLS"
)
if cls.get_redmine_verify() is False:
print(
"⚠️ TLS certificate verification is disabled "
"(REDMINE_VERIFY=false / verify_ssl: false): connection is "
"vulnerable to MITM attacks",
file=sys.stderr,
)
if cls.get_redmine_api_key():
return
if not (cls.get_redmine_user() and cls.get_redmine_password()):

View File

@@ -39,9 +39,6 @@ class XLSXFormatter(Formatter):
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 = [

View File

@@ -1,6 +1,5 @@
"""Отправка сгенерированного отчёта по email через SMTP."""
import email.charset as _charset
import os
import smtplib
from email.mime.application import MIMEApplication
@@ -12,10 +11,6 @@ from .client import RedmineAPIError
from .config import EmailConfig
from .types import ReportRow
# Use 8bit transfer encoding for UTF-8 so non-ASCII text (e.g. Russian)
# appears literally in MIME output instead of base64.
_charset.add_charset("utf-8", _charset.SHORTEST, None, "utf-8")
SMTP_TIMEOUT = 30
MIME_TYPES: Dict[str, str] = {
@@ -34,6 +29,20 @@ def _resolve_mime_type(file_path: str) -> str:
return MIME_TYPES.get(ext, "application/octet-stream")
def _utf8_text_part(text: str, subtype: str = "plain") -> MIMEText:
"""Создаёт текстовую MIME-часть с 8bit UTF-8 телом.
Кодировка применяется точечно к части письма, без глобальной мутации
реестра email.charset: payload хранится как UTF-8 (surrogateescape),
а Content-Transfer-Encoding выставляется в 8bit, чтобы не-ASCII текст
(например, русский) передавался литерально, а не в base64.
"""
part = MIMEText("", subtype, "utf-8")
part.set_payload(text.encode("utf-8").decode("ascii", "surrogateescape"))
part.replace_header("Content-Transfer-Encoding", "8bit")
return part
def _build_html_body(rows: List[ReportRow]) -> str:
"""Генерирует HTML-версию тела письма через HTMLFormatter."""
from .formatters.html import HTMLFormatter
@@ -66,11 +75,11 @@ def build_message(
# Тела письма: plain-text всегда, HTML по флагу
body_container = MIMEMultipart("alternative")
body_container.attach(MIMEText(body, "plain", "utf-8"))
body_container.attach(_utf8_text_part(body))
if email_config.html:
html_body = _build_html_body(rows)
body_container.attach(MIMEText(html_body, "html", "utf-8"))
body_container.attach(_utf8_text_part(html_body, "html"))
msg.attach(body_container)

View File

@@ -35,6 +35,7 @@ def build_grouped_report(
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]],
fill_time: bool = True,
by_activity: bool = False,
status_translation: Optional[Dict[str, str]] = None,
) -> List[ReportRow]:
"""
Преобразует список задач с затраченным временем в плоский список строк отчёта,
@@ -42,6 +43,11 @@ def build_grouped_report(
Предусловие: issue_hours должен быть отсортирован по (project, version).
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
status_translation: перевод статусов; None — встроенный STATUS_TRANSLATION.
Переданный словарь ЗАМЕНЯЕТ встроенный полностью (без merge): статус,
отсутствующий в нём, выводится как есть (passthrough); пустой dict —
все статусы выводятся как есть.
"""
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
@@ -49,6 +55,10 @@ def build_grouped_report(
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
)
translation = (
status_translation if status_translation is not None else STATUS_TRANSLATION
)
rows: List[ReportRow] = []
prev_project: str = ""
prev_version: str = ""
@@ -58,7 +68,7 @@ def build_grouped_report(
project = str(issue.project)
version = get_version(issue)
status_en = str(issue.status)
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
status_ru = translation.get(status_en, status_en)
if fill_time:
if by_activity and activities:

View File

@@ -30,6 +30,10 @@ def _reset_config_overrides():
[
("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")),
(" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")),
(
"2026-06-01T00:00:00--2026-06-30T23:59:59",
("2026-06-01T00:00:00", "2026-06-30T23:59:59"),
),
],
)
def test_parse_date_range_valid(date_arg, expected):
@@ -43,6 +47,9 @@ def test_parse_date_range_valid(date_arg, expected):
"2026-1-01--2026-01-31",
"2026-02-30--2026-03-01",
"2026-02-01--2026-01-31",
"2026-06-01T25:00:00--2026-06-30T23:59:59",
"2026-06-01--2026-06-30T23:59:59",
"2026-06-30T23:59:59--2026-06-01T00:00:00",
],
)
def test_parse_date_range_invalid(date_arg):
@@ -91,7 +98,7 @@ class _MockIssue:
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
"""Неизвестное расширение файла -- выход 1."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
output = str(tmp_path / "report.xyz")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1
@@ -103,7 +110,7 @@ def test_cli_unknown_output_extension(mock_fetch, tmp_path):
def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
"""Файл без расширения — приложение расширяет default_format (#43)."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
output = str(tmp_path / "report")
@@ -128,7 +135,7 @@ def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
"""При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
mock_gf.return_value = None
output = str(tmp_path / "report.odt")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
@@ -198,7 +205,7 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
"""--summary выводит общее время и разбивку по проектам в stderr."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
main(["--date", "2026-01-01--2026-01-31", "--summary"])
captured = capsys.readouterr()
assert "Total time" in captured.err
@@ -210,7 +217,7 @@ def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert "Total issues" not in captured.out
@@ -264,7 +271,7 @@ def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
"""CLI --no-time передаётся в файловый форматтер."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
output = str(tmp_path / "report.xlsx")
with mock.patch(
@@ -418,6 +425,49 @@ class TestInitConfig:
assert data["redmine"]["url"] == ""
assert data["redmine"]["api_key"] == ""
def test_init_config_reads_dotenv_file(self, tmp_path, monkeypatch):
"""#64: --init-config подхватывает значения из реального .env в cwd.
Регрессионный тест: после 1df1194 load_dotenv перестал выполняться
до _run_init_config, и пользователь с настройками только в .env
получал пустой YAML.
"""
import yaml
(tmp_path / ".env").write_text(
"REDMINE_URL=https://redmine.example.com\n"
"REDMINE_API_KEY=secret-from-dotenv\n"
"REDMINE_AUTHOR=ivan.ivanov\n"
"DEFAULT_FROM_DATE=2026-01-01\n"
"DEFAULT_TO_DATE=2026-01-31\n"
"SMTP_PASSWORD=smtp-secret\n"
)
monkeypatch.chdir(tmp_path)
# Целевые переменные не мокаются: значения должны прийти только из .env.
for var in (
"REDMINE_URL",
"REDMINE_API_KEY",
"REDMINE_AUTHOR",
"DEFAULT_FROM_DATE",
"DEFAULT_TO_DATE",
"SMTP_PASSWORD",
):
monkeypatch.delenv(var, raising=False)
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["redmine"]["url"] == "https://redmine.example.com"
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
assert data["redmine"]["author"] == "ivan.ivanov"
assert data["period"]["default_from"] == "2026-01-01"
assert data["period"]["default_to"] == "2026-01-31"
assert data["email"]["smtp"]["password"] == "${SMTP_PASSWORD}"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_includes_report_section(self, tmp_path):
"""--init-config генерирует секцию report с no_time: false."""
@@ -433,6 +483,55 @@ class TestInitConfig:
assert "report" in data
assert data["report"]["no_time"] is False
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_includes_status_translation_example(self, tmp_path):
"""#65: --init-config дописывает закомментированный пример status_translation.
Файл остаётся валидным YAML, активной секции status_translation нет.
"""
import yaml
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
text = config_path.read_text(encoding="utf-8")
assert "# status_translation:" in text
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert "status_translation" not in data["report"]
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_status_translation_from_yaml_reaches_builder(mock_fetch, tmp_path):
"""#65: report.status_translation из YAML доезжает до build_grouped_report."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
"report:\n"
" status_translation:\n"
' New: "Новая"\n'
' "Custom Status": "Кастом"\n'
)
with mock.patch("redmine_reporter.cli.build_grouped_report") as mock_build:
mock_build.return_value = []
code = main(
["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)]
)
assert code == 0
_, kwargs = mock_build.call_args
translation = kwargs["status_translation"]
assert translation["New"] == "Новая"
assert translation["Custom Status"] == "Кастом"
# встроенные переводы сохранились
assert translation["Closed"] == "Закрыто"
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@@ -495,7 +594,7 @@ class TestOutputPathResolution:
):
"""--output report без расширения → добавляет .xlsx."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
output = str(tmp_path / "report")
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
@@ -521,7 +620,7 @@ class TestOutputPathResolution:
def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path):
"""--output report.csv с явным расширением передаётся как есть."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
@@ -546,7 +645,7 @@ class TestOutputPathResolution:
def test_output_bare_format_invalid_is_treated_as_path(self, mock_fetch, tmp_path):
"""--output notanxlsx (no path, not known format) → treated as path + .xlsx appended."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
@@ -583,7 +682,7 @@ class TestCommitFlag:
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
@@ -620,7 +719,7 @@ class TestCommitFlag:
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
@@ -649,6 +748,49 @@ class TestCommitFlag:
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")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_with_precision_datetime_saves_aware_utc(
self, mock_save, mock_fetch, tmp_path
):
"""При precision=datetime --commit сохраняет last_used как aware UTC (#58)."""
from datetime import datetime, timezone
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-30--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
call_args = mock_save.call_args
assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2]
for saved in (saved_from, saved_to):
parsed = datetime.fromisoformat(saved)
assert parsed.tzinfo is not None, f"{saved} must be timezone-aware"
assert parsed.utcoffset() == timezone.utc.utcoffset(None)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
@@ -679,7 +821,7 @@ class TestCommitFlag:
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
@@ -716,7 +858,7 @@ class TestCommitFlag:
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
@@ -757,7 +899,7 @@ class TestCommitFlag:
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
@@ -825,7 +967,7 @@ class TestSendFlag:
def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path):
"""--send с --output вызывает send_report после сохранения."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
@@ -866,7 +1008,7 @@ class TestSendFlag:
):
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
@@ -905,7 +1047,7 @@ class TestSendFlag:
def test_send_without_email_config_is_error(self, mock_fetch, tmp_path, capsys):
"""--send без email-конфига — ошибка и выход 1."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text("redmine:\n url: https://x.com\n api_key: token\n")
@@ -936,7 +1078,7 @@ class TestSendFlag:
from redmine_reporter.client import RedmineAPIError
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_send.side_effect = RedmineAPIError(
@@ -981,7 +1123,7 @@ class TestSendFlag:
):
"""--send и --commit работают вместе без конфликтов."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
@@ -1038,7 +1180,7 @@ class TestSendHtmlBody:
):
"""--send передаёт rows в send_report для генерации HTML."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
@@ -1083,7 +1225,7 @@ class TestSendHtmlBody:
):
"""При email.html: false письмо отправляется без HTML-части."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
@@ -1167,7 +1309,7 @@ class TestReportNoTimeIntegration:
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
@@ -1207,7 +1349,7 @@ class TestReportNoTimeIntegration:
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
@@ -1247,7 +1389,7 @@ class TestReportNoTimeIntegration:
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
@@ -1287,7 +1429,7 @@ class TestReportNoTimeIntegration:
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
@@ -1325,7 +1467,7 @@ class TestReportNoTimeIntegration:
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
@@ -1365,3 +1507,156 @@ class TestReportNoTimeIntegration:
_, kwargs = mock_get.call_args
assert kwargs.get("no_time") is True
# ---------------------------------------------------------------------------
# #59: dynamic + precision=datetime без --date
# ---------------------------------------------------------------------------
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
"""dynamic+datetime без --date: datetime-диапазон из last_used принимается.
Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон
с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used,
отчёт строится без ошибки.
"""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
"period:\n"
" precision: datetime\n"
" dynamic: true\n"
" last_used:\n"
" from: '2026-06-01T00:00:00'\n"
" to: '2026-06-30T23:59:59'\n"
)
code = main(["--config-path", str(config_path)])
assert code == 0
args, _ = mock_fetch.call_args
from_date, to_date = args[0], args[1]
# Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59
assert from_date == "2026-07-01T00:00:00"
assert to_date == "2026-07-30T23:59:59"
# ---------------------------------------------------------------------------
# #55: sanitize Redmine exception output in CLI
# ---------------------------------------------------------------------------
class TestSanitizeErrorOutput:
"""CLI не должен выводить API-ключ из текста исключений в stderr (#55)."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_api_key_in_url_is_masked(self, mock_fetch, capsys):
"""key=SECRET в URL внутри сообщения исключения маскируется."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError(
"Network error while calling Redmine: "
"HTTPSConnectionPool(host='red.example.com', port=443): "
"Failed https://red.example.com/issues.json?key=SECRET123&limit=100"
)
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "SECRET123" not in captured.err
assert "key=***" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_api_key_header_is_masked(self, mock_fetch, capsys):
"""Заголовок X-Redmine-API-Key в тексте исключения маскируется."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError(
"Request failed: headers {'X-Redmine-API-Key': 'SECRET123'}"
)
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "SECRET123" not in captured.err
assert "X-Redmine-API-Key" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_unexpected_exception_is_masked(self, mock_fetch, capsys):
"""Общее исключение с key=SECRET в тексте тоже санитизируется."""
mock_fetch.side_effect = RuntimeError(
"boom while requesting https://red.example.com/t.json?key=SECRET123"
)
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "SECRET123" not in captured.err
assert "Unexpected error" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_error_message_readability_preserved(self, mock_fetch, capsys):
"""Санитизация не ломает обычные сообщения об ошибках."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "Authentication failed: bad key" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_debug_shows_full_traceback(self, mock_fetch, caplog):
"""--debug выводит полный стектрейс исходного исключения."""
import logging as _logging
from redmine_reporter.client import RedmineAPIError
original = ValueError("raw details https://red.example.com?key=SECRET123")
error = RedmineAPIError("safe message", original=original)
error.__cause__ = original
mock_fetch.side_effect = error
with caplog.at_level(_logging.ERROR):
code = main(["--date", "2026-01-01--2026-01-31", "--debug"])
assert code == 1
assert "raw details" in caplog.text
assert "Traceback" in caplog.text
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_verbose_shows_full_traceback(self, mock_fetch, caplog):
"""--verbose тоже даёт доступ к деталям/стектрейсу исходной ошибки."""
import logging as _logging
from redmine_reporter.client import RedmineAPIError
original = ValueError("raw details https://red.example.com?key=SECRET123")
error = RedmineAPIError("safe message", original=original)
error.__cause__ = original
mock_fetch.side_effect = error
with caplog.at_level(_logging.INFO):
code = main(["--date", "2026-01-01--2026-01-31", "--verbose"])
assert code == 1
assert "raw details" in caplog.text
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_no_flags_hides_original_details(self, mock_fetch, caplog, capsys):
"""Без флагов детали исходного исключения не печатаются."""
from redmine_reporter.client import RedmineAPIError
original = ValueError("raw details https://red.example.com?key=SECRET123")
mock_fetch.side_effect = RedmineAPIError("safe message", original=original)
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "raw details" not in captured.err
assert "raw details" not in caplog.text
assert "safe message" in captured.err

View File

@@ -19,6 +19,18 @@ def _configure_current_user(mock_redmine, user_id=1):
mock_redmine.user.get.return_value = mock_user
@pytest.fixture(autouse=True)
def mock_truststore():
"""Мок truststore для всех тестов клиента.
При verify=True _create_redmine() вызывает truststore.inject_into_ssl(),
который глобально подменяет ssl.SSLContext без восстановления (#62).
Не даём реальной инъекции выполниться в pytest-процессе.
"""
with mock.patch("redmine_reporter.client.truststore") as m:
yield m
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
@@ -172,6 +184,70 @@ def test_fetch_uses_custom_verify_path(mock_redmine_class):
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "true"}, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_true_injects_system_trust_store(mock_redmine_class, mock_truststore):
"""verify=True → truststore.inject_into_ssl() вызван, verify=True в Redmine (#62)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_called_once_with()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is True
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "false"}, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_false_skips_truststore_injection(mock_redmine_class, mock_truststore):
"""verify=False → truststore.inject_into_ssl() не вызывается."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_not_called()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is False
@mock.patch.dict(
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_custom_path_skips_truststore_injection(
mock_redmine_class, mock_truststore
):
"""verify=<путь к CA-bundle> → truststore.inject_into_ssl() не вызывается."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_not_called()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_default_injects_system_trust_store(mock_redmine_class, mock_truststore):
"""Без REDMINE_VERIFY verify по умолчанию True → inject вызван (#62)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_called_once_with()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is True
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
@@ -275,6 +351,7 @@ def test_fetch_resolves_user_login_to_id(mock_redmine_class):
mock_user = mock.MagicMock()
mock_user.id = 7
mock_user.login = "ivanov"
mock_redmine.user.filter.return_value = [mock_user]
mock_redmine.time_entry.filter.return_value = []
@@ -287,6 +364,91 @@ def test_fetch_resolves_user_login_to_id(mock_redmine_class):
assert kwargs["user_id"] == 7
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_inexact_login_match(mock_redmine_class):
"""Фильтр Redmine по логину неточный (#60): без точного совпадения — ошибка."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
near_match = mock.MagicMock()
near_match.id = 8
near_match.login = "ivanov2"
mock_redmine.user.filter.side_effect = [
[near_match], # неточное совпадение по логину
[], # по имени никого не найдено
]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="not found"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_resolves_exact_login_among_near_matches(mock_redmine_class):
"""Среди неточных результатов фильтра выбирается точное совпадение логина (#60)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
near = mock.MagicMock()
near.id = 8
near.login = "ivanov2"
exact = mock.MagicMock()
exact.id = 7
exact.login = "ivanov"
mock_redmine.user.filter.return_value = [near, exact]
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 7
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_multiple_exact_login_matches(mock_redmine_class):
"""Несколько пользователей с точным логином — ошибка неоднозначности (#60)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
user1 = mock.MagicMock()
user1.id = 7
user1.login = "ivanov"
user2 = mock.MagicMock()
user2.id = 9
user2.login = "ivanov"
mock_redmine.user.filter.return_value = [user1, user2]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Multiple users match"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_login_exact_match_is_case_sensitive(mock_redmine_class):
"""Точное совпадение логина регистрозависимое (#60)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
other_case = mock.MagicMock()
other_case.id = 8
other_case.login = "Ivanov"
mock_redmine.user.filter.side_effect = [
[other_case], # логин отличается регистром
[], # по имени никого не найдено
]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="not found"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_resolves_user_name_to_id(mock_redmine_class):
@@ -691,3 +853,303 @@ def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
assert len(result) == 2
ids = {r[0].id for r in result}
assert ids == {20, 30}
# -- Пагинация time entries (>100 записей) --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
"""Time entries приходят страницами по 100 — часы агрегируются по всем страницам."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
def make_entry(issue_id, hours):
e = mock.MagicMock()
e.issue.id = issue_id
e.hours = hours
return e
# Эмулируем ленивую пагинацию redminelib ResourceSet: записи отдаются
# генератором порциями ("страницами" по 100), как при догрузке с сервера.
# Если клиент прочитает только первую страницу, агрегация будет неверной.
pages = [
[make_entry(1, 0.5) for _ in range(100)],
[make_entry(2, 1.0) for _ in range(100)],
[make_entry(1, 0.5) for _ in range(50)],
]
def paged_entries():
for page in pages:
yield from page
mock_redmine.time_entry.filter.return_value = paged_entries()
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_issue2.project = "P"
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time("2026-01-01", "2026-03-31")
assert result is not None
hours_by_id = {issue.id: hours for issue, hours, _ in result}
# issue 1: 100×0.5 (стр. 1) + 50×0.5 (стр. 3) = 75.0
assert hours_by_id[1] == 75.0
# issue 2: 100×1.0 (стр. 2) = 100.0
assert hours_by_id[2] == 100.0
# -- #58: дедупликация при precision=datetime с naive created_on --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class):
"""python-redmine отдаёт naive created_on; cutoff aware (UTC) — фильтрация не падает.
Ожидаемое поведение после фикса #58: naive datetime трактуется как UTC,
старые записи отсекаются, новые сохраняются.
"""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e_old = mock.MagicMock()
e_old.issue.id = 1
e_old.hours = 2.0
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0) # naive, как из python-redmine
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
e_new = mock.MagicMock()
e_new.issue.id = 2
e_new.hours = 1.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0) # naive
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_issue2.project = "P"
mock_issue2.subject = "T"
mock_issue2.status = "New"
# API возвращает обе задачи: issue 1 попадёт в результат, только если
# её time entry не отсечён дедупликацией. Корректный фикс #58 отсекает
# e_old (created_on/updated_on < cutoff) -> [2]; неправильный фикс
# "погасить TypeError и оставить всё" даст [1, 2] и тест упадёт.
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None
assert [r[0].id for r in result] == [2]
# -- #58: нормализация datetime к aware UTC --
class TestParseDatetimeNormalization:
"""_parse_datetime всегда возвращает aware datetime; naive трактуется как UTC."""
def test_naive_datetime_treated_as_utc(self):
from datetime import datetime, timezone
from redmine_reporter.client import _parse_datetime
result = _parse_datetime(datetime(2026, 7, 1, 10, 0, 0))
assert result == datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
def test_aware_datetime_kept(self):
from datetime import datetime, timedelta, timezone
from redmine_reporter.client import _parse_datetime
aware = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone(timedelta(hours=3)))
assert _parse_datetime(aware) is aware
def test_naive_iso_string_treated_as_utc(self):
from datetime import timezone
from redmine_reporter.client import _parse_datetime
result = _parse_datetime("2026-07-01T10:00:00")
assert result is not None
assert result.tzinfo == timezone.utc
def test_aware_iso_string_keeps_offset(self):
from datetime import timedelta
from redmine_reporter.client import _parse_datetime
result = _parse_datetime("2026-07-01T10:00:00+03:00")
assert result is not None
assert result.utcoffset() == timedelta(hours=3)
def test_none_and_garbage_return_none(self):
from redmine_reporter.client import _parse_datetime
assert _parse_datetime(None) is None
assert _parse_datetime("not a date") is None
assert _parse_datetime(123) is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_accepts_naive_cutoff(mock_redmine_class):
"""Naive dedup_before трактуется как UTC — сравнение не падает (#58)."""
from datetime import datetime
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
naive_cutoff = datetime(2026, 7, 1, 12, 0, 0) # naive == UTC
e_old = mock.MagicMock()
e_old.issue.id = 1
e_old.hours = 2.0
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0)
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
e_new = mock.MagicMock()
e_new.issue.id = 2
e_new.hours = 1.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0)
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=naive_cutoff
)
assert result is not None
assert [r[0].id for r in result] == [2]
# -- #61: предупреждения о молчаливой потере данных --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_warns_about_missing_issues(mock_redmine_class, capsys):
"""Задачи, не вернувшиеся из issue.filter, попадают в warning в stderr (#61).
Отчёт при этом строится по доступным данным — это предупреждение, не ошибка.
"""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
def make_entry(issue_id, hours):
e = mock.MagicMock()
e.issue.id = issue_id
e.hours = hours
return e
mock_redmine.time_entry.filter.return_value = [
make_entry(1, 1.0),
make_entry(2, 2.5),
make_entry(3, 0.75),
]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
# Задачи 2 и 3 не вернулись (нет прав / удалены)
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
assert [r[0].id for r in result] == [1]
captured = capsys.readouterr()
assert "2" in captured.err
assert "3" in captured.err
assert "3.25" in captured.err # сумма потерянных часов 2.5 + 0.75
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_no_warning_when_all_issues_returned(mock_redmine_class, capsys):
"""Если все задачи вернулись, stderr остаётся чистым (#61)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
e = mock.MagicMock()
e.issue.id = 1
e.hours = 1.0
mock_redmine.time_entry.filter.return_value = [e]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
captured = capsys.readouterr()
assert captured.err == ""
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_warns_when_activities_lookup_fails(mock_redmine_class, capsys):
"""Сбой справочника активностей — warning в stderr, отчёт строится (#61)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.enumeration.filter.side_effect = Exception("enumeration boom")
e = mock.MagicMock()
e.issue.id = 1
e.hours = 1.0
e.activity.id = 9
e.activity.name = "Dev"
mock_redmine.time_entry.filter.return_value = [e]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31", by_activity=True)
assert result is not None
assert result[0][2] == {"Dev": 1.0} # fallback на имя из самого entry
captured = capsys.readouterr()
assert "activit" in captured.err.lower()
def test_load_time_entry_activities_returns_empty_on_failure(capsys):
"""_load_time_entry_activities возвращает {} и пишет warning при сбое (#61)."""
from redmine_reporter.client import _load_time_entry_activities
redmine = mock.MagicMock()
redmine.enumeration.filter.side_effect = Exception("enumeration boom")
assert _load_time_entry_activities(redmine) == {}
captured = capsys.readouterr()
assert "activit" in captured.err.lower()

View File

@@ -5,12 +5,7 @@ from unittest import mock
import pytest
from redmine_reporter.config import (
DEFAULT_REDMINE_VERIFY,
AppConfig,
Config,
EmailConfig,
)
from redmine_reporter.config import AppConfig, Config, EmailConfig
@mock.patch.dict(
@@ -79,26 +74,89 @@ def test_get_default_date_range_from_env():
assert Config.get_default_date_range() == "2026-01-01--2026-01-31"
@mock.patch.dict(
os.environ,
{"DEFAULT_FROM_DATE": "2026-01-01"},
clear=True,
)
def test_get_default_date_range_from_env_without_to():
"""Если DEFAULT_TO_DATE не задан, конец периода — сегодня."""
from datetime import date
today = date.today().isoformat()
assert Config.get_default_date_range() == f"2026-01-01--{today}"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_default_date_range_fallback():
"""Если даты не заданы -- используется текущий месяц."""
from datetime import date, timedelta
"""Если даты не заданы -- используется текущий месяц с начала до сегодня."""
from datetime import date
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()}"
assert result == f"{start.isoformat()}--{today.isoformat()}"
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) --
class TestDefaultPeriodIsCurrentMonth:
"""Без --date, env и YAML период = с 1-го числа текущего месяца по сегодня."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_mid_month(self):
from datetime import date
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 3, 14)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
Config._app = None
assert Config.get_default_date_range() == "2026-03-01--2026-03-14"
@mock.patch.dict(os.environ, {}, clear=True)
def test_first_day_of_month(self):
from datetime import date
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 5, 1)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
Config._app = None
assert Config.get_default_date_range() == "2026-05-01--2026-05-01"
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_default_from_falls_back_to_today(self):
"""default_from задан, default_to нет → конец периода = сегодня."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n default_from: '2026-04-01'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 4, 20)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
assert Config.get_default_date_range() == "2026-04-01--2026-04-20"
@mock.patch.dict(os.environ, {"DEFAULT_FROM_DATE": "2026-02-01"}, clear=True)
def test_env_from_without_to_falls_back_to_today(self):
"""DEFAULT_FROM_DATE задана, DEFAULT_TO_DATE нет → конец = сегодня."""
from datetime import date
Config._app = None
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 2, 10)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
assert Config.get_default_date_range() == "2026-02-01--2026-02-10"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_redmine_verify_default():
assert Config.get_redmine_verify() == DEFAULT_REDMINE_VERIFY
"""Без явной настройки проверка TLS включена (стандартные CA requests)."""
Config._app = None
assert Config.get_redmine_verify() is True
@pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"])
@@ -118,17 +176,103 @@ def test_get_redmine_verify_custom_path():
assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem"
# -- #54: REDMINE_URL обязан использовать HTTPS --
class TestConfigRequiresHttpsUrl:
"""Config.validate() отклоняет URL без TLS: API-ключ идёт в заголовках."""
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "http://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_http_url_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "red.eltex.loc", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_url_without_scheme_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "ftp://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_non_http_scheme_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_https_url_accepted(self):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "HTTPS://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_uppercase_scheme_accepted(self):
Config.validate()
# -- #57: предупреждение при отключённой проверке TLS --
class TestWarnWhenTlsVerificationDisabled:
"""verify_ssl: false должен давать видимый warning в stderr (риск MITM)."""
_ENV = {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"}
def _validate_env(self, verify: str):
return {**self._ENV, "REDMINE_VERIFY": verify}
def test_verify_false_warns_in_stderr(self, capsys):
with mock.patch.dict(os.environ, self._validate_env("false"), clear=True):
Config.validate()
err = capsys.readouterr().err
assert "⚠️" in err
assert "TLS" in err or "SSL" in err
def test_verify_true_no_warning(self, capsys):
with mock.patch.dict(os.environ, self._validate_env("true"), clear=True):
Config.validate()
assert capsys.readouterr().err == ""
def test_verify_ca_path_no_warning(self, capsys):
with mock.patch.dict(
os.environ, self._validate_env("/tmp/redmine-ca.pem"), clear=True
):
Config.validate()
assert capsys.readouterr().err == ""
def test_verify_default_no_warning(self, capsys):
with mock.patch.dict(os.environ, self._ENV, clear=True):
Config.validate()
assert capsys.readouterr().err == ""
# -- #15: .env не должен переопределять переменные окружения --
@mock.patch("dotenv.load_dotenv")
@mock.patch("redmine_reporter.config.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)
Config.load_yaml("/nonexistent/config.yml")
mock_load.assert_called_once_with(override=False)
@@ -257,16 +401,57 @@ class TestAppConfigFromYaml:
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."""
def test_verify_ssl_true_returns_true(self):
"""verify_ssl: true → bool True (стандартная проверка TLS)."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n verify_ssl: true\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify == DEFAULT_REDMINE_VERIFY
assert cfg.redmine_verify is not True # не бул!
assert cfg.redmine_verify is True
@pytest.mark.parametrize("value", ["true", "True", "yes", "on", "1"])
def test_verify_ssl_true_string_returns_true(self, value):
"""Строковые true-значения в YAML → bool True, как и из env."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify is True
def test_verify_ssl_custom_path_returns_path(self):
"""Явный путь к CA-bundle в YAML передаётся как есть."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n verify_ssl: /etc/ssl/my-ca.pem\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify == "/etc/ssl/my-ca.pem"
@pytest.mark.parametrize("value", ["false", "False", "no", "off", "0"])
def test_verify_ssl_false_string_returns_false(self, value):
"""Строковые false-значения в YAML → bool False."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify is False
def test_verify_ssl_missing_returns_true(self):
"""Без verify_ssl в YAML — проверка TLS включена (True)."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n url: https://x.com/\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify is True
def test_verify_ssl_false_returns_false(self):
with tempfile.TemporaryDirectory() as tmp:
@@ -567,6 +752,24 @@ class TestDefaultDateRangeWithLastUsed:
result = Config.get_default_date_range()
assert result == "2026-04-01--2026-04-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_falls_back_to_today_when_default_to_missing(self):
"""Если default_from задан, а default_to нет — конец периода сегодня."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-04-01'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
today = date.today().isoformat()
result = Config.get_default_date_range()
assert result == f"2026-04-01--{today}"
class TestGetEmailConfig:
"""Tests for Config.get_email_config()."""
@@ -704,3 +907,169 @@ class TestReportNoTime:
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_report_no_time() is False
# ---------------------------------------------------------------------------
# #65: report.status_translation — перевод статусов из YAML
# ---------------------------------------------------------------------------
class TestStatusTranslationFromYaml:
"""Tests for report.status_translation loading in AppConfig.from_yaml()."""
def test_status_translation_loaded_from_yaml(self):
"""report.status_translation заполняет поле AppConfig (override + новый)."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n"
" status_translation:\n"
' New: "Новая"\n'
' "Custom Status": "Кастом"\n'
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {
"New": "Новая",
"Custom Status": "Кастом",
}
def test_status_translation_missing_defaults_to_empty(self):
"""Без секции report.status_translation поле пустое."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n no_time: true\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {}
def test_status_translation_non_mapping_warns_and_ignored(self, caplog):
"""status_translation не-mapping (строка) → warning в лог, поле {}."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n status_translation: 'just a string'\n")
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {}
assert "status_translation" in caplog.text
@mock.patch.dict(os.environ, {"STATUS_RU": "На проверке"}, clear=True)
def test_status_translation_env_var_resolved(self):
"""${VAR} в значении перевода резолвится из окружения."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n status_translation:\n Testing: ${STATUS_RU}\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Testing": "На проверке"}
def test_status_translation_null_value_skipped_with_warning(self, caplog):
"""Значение null пропускается с warning, а не превращается в 'None'."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n status_translation:\n New:\n Closed: Закрыто\n"
)
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Closed": "Закрыто"}
assert "status_translation" in caplog.text
assert "New" in caplog.text
def test_status_translation_nested_dict_skipped_with_warning(self, caplog):
"""Вложенный dict в значении пропускается с warning, а не в repr."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n"
" status_translation:\n"
" New:\n"
" nested: value\n"
" Closed: Закрыто\n"
)
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Closed": "Закрыто"}
assert "status_translation" in caplog.text
assert "New" in caplog.text
def test_status_translation_int_value_coerced_to_str(self):
"""Scalar-значения (int) по-прежнему приводятся к str."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n status_translation:\n New: 42\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"New": "42"}
class TestGetStatusTranslation:
"""Tests for Config.get_status_translation()."""
def test_no_yaml_returns_builtin(self):
"""Без YAML-конфига возвращается встроенный словарь."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = None
try:
assert Config.get_status_translation() == STATUS_TRANSLATION
finally:
Config._app = None
def test_empty_overrides_returns_builtin(self):
"""Пустые overrides — результат равен встроенному словарю."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = AppConfig()
try:
assert Config.get_status_translation() == STATUS_TRANSLATION
finally:
Config._app = None
def test_override_and_extend(self):
"""Override существующего статуса + добавление нового."""
Config._app = AppConfig(
report_status_translation={"New": "Новая", "Custom Status": "Кастом"}
)
try:
translation = Config.get_status_translation()
finally:
Config._app = None
assert translation["New"] == "Новая"
assert translation["Custom Status"] == "Кастом"
# остальные встроенные переводы не тронуты
assert translation["Closed"] == "Закрыто"
def test_result_is_a_copy(self):
"""Мутация результата не трогает модульный STATUS_TRANSLATION."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = AppConfig(report_status_translation={"New": "Новая"})
try:
translation = Config.get_status_translation()
translation["New"] = "ИЗМЕНЕНО"
translation["Junk"] = "мусор"
finally:
Config._app = None
assert STATUS_TRANSLATION["New"] == "В работе"
assert "Junk" not in STATUS_TRANSLATION

View File

@@ -396,6 +396,32 @@ def test_xlsx_has_totals(fake_rows, tmp_path):
assert ws["F2"].number_format == "0.00"
def test_xlsx_has_full_header_row_and_grand_total_row(fake_rows, tmp_path):
"""XLSX содержит полный ряд заголовков колонок и финальную строку общего итога."""
from openpyxl import load_workbook
output = tmp_path / "report.xlsx"
XLSXFormatter().save(fake_rows, str(output))
wb = load_workbook(str(output))
ws = wb.active
headers = [ws.cell(row=1, column=c).value for c in range(1, 8)]
assert headers == [
"Project",
"Version",
"Issue ID",
"Subject",
"Status",
"Hours",
"Spent Time",
]
# Последняя строка — общий итог по всем проектам
assert ws.cell(row=ws.max_row, column=1).value == "Total"
assert ws.cell(row=ws.max_row, column=6).value == 22.5
def test_xlsx_no_time_keeps_columns_empty_and_skips_totals(fake_rows, tmp_path):
"""XLSX с no_time: колонки времени пустые, итогов нет."""
from openpyxl import load_workbook
@@ -456,6 +482,25 @@ def test_html_output_has_doctype_and_charset(fake_rows):
assert '<meta charset="utf-8">' in output
def test_html_has_table_structure_with_all_columns(fake_rows):
"""HTML-отчёт содержит thead со всеми колонками и по строке на каждую задачу."""
output = HTMLFormatter().format(fake_rows)
assert "<thead>" in output
assert "<tbody>" in output
for header in (
"Наименование Проекта",
"Номер версии*",
"Задача",
"Статус Готовность*",
"Затрачено за отчетный период",
):
assert f"<th>{header}</th>" in output
tbody = output.split("<tbody>", 1)[1]
assert tbody.count("<tr>") == len(fake_rows)
# -- Тесты ODT форматтера --

View File

@@ -0,0 +1,50 @@
"""#64: импорт модулей не должен давать side effects в процессе."""
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def _run_in_subprocess(code: str, cwd: Path) -> str:
"""Выполняет code в чистом интерпретаторе с доступом к пакету."""
env = {**os.environ, "PYTHONPATH": str(REPO_ROOT)}
result = subprocess.run(
[sys.executable, "-c", code],
cwd=cwd,
env=env,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def test_config_import_does_not_mutate_os_environ(tmp_path):
"""import redmine_reporter.config не подгружает .env из текущей директории."""
(tmp_path / ".env").write_text("RR_IMPORT_PROBE=1\n")
output = _run_in_subprocess(
"import os\n"
"import redmine_reporter.config\n"
"print('RR_IMPORT_PROBE' in os.environ)\n",
cwd=tmp_path,
)
assert output == "False"
def test_mailer_import_does_not_mutate_global_charset_registry(tmp_path):
"""import redmine_reporter.mailer не меняет email.charset для всего процесса."""
output = _run_in_subprocess(
"from email.charset import BASE64, Charset\n"
"before = Charset('utf-8').body_encoding\n"
"import redmine_reporter.mailer\n"
"after = Charset('utf-8').body_encoding\n"
"print(before == BASE64, after == BASE64)\n",
cwd=tmp_path,
)
assert output == "True True"

View File

@@ -49,7 +49,37 @@ class TestBuildMessage:
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", [])
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
assert len(plain_parts) == 1
assert "Автор: Иванов, период: Q1" in plain_parts[0].as_string()
payload = plain_parts[0].get_payload(decode=True).decode("utf-8")
assert "Автор: Иванов, период: Q1" in payload
def test_wire_format_is_8bit_utf8(self):
"""Сериализация в байты (как в smtplib.send_message) — 8bit UTF-8."""
from email.generator import BytesGenerator
from io import BytesIO
cfg = _make_email_config(body_text="Автор: {author}", attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
buf = BytesIO()
BytesGenerator(buf, policy=msg.policy.clone(linesep="\r\n")).flatten(msg)
data = buf.getvalue()
assert b"Content-Transfer-Encoding: 8bit" in data
assert "Автор: Иванов".encode("utf-8") in data
def test_plain_part_uses_8bit_utf8(self):
"""Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть."""
cfg = _make_email_config(
body_text="Автор: {author}, период: {period}", attach=False
)
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
plain = [p for p in msg.walk() if p.get_content_type() == "text/plain"][0]
assert plain["Content-Transfer-Encoding"] == "8bit"
assert (
plain.get_payload(decode=True).decode("utf-8")
== "Автор: Иванов, период: Q1"
)
def test_from_header(self):
cfg = _make_email_config(from_="sender@example.com", attach=False)

View File

@@ -277,3 +277,27 @@ def test_group_rows_preserves_row_data():
def test_group_rows_empty():
"""Пустой список — пустой словарь."""
assert group_rows_by_project_and_version([]) == {}
# -- #65: кастомный перевод статусов --
def test_build_grouped_report_custom_status_translation():
"""status_translation переопределяет встроенный словарь (#65)."""
issue = MockIssue("P", "S", "New", "v1.0", 1)
rows = build_grouped_report([(issue, 1.0)], status_translation={"New": "Новая"})
assert rows[0]["status_ru"] == "Новая"
def test_build_grouped_report_custom_status_translation_passthrough():
"""Статус вне кастомного словаря возвращается как есть (#65)."""
issue = MockIssue("P", "S", "Closed", "v1.0", 1)
rows = build_grouped_report([(issue, 1.0)], status_translation={"New": "Новая"})
assert rows[0]["status_ru"] == "Closed"
def test_build_grouped_report_none_translation_uses_builtin():
"""status_translation=None — используется встроенный словарь (#65)."""
issue = MockIssue("P", "S", "Closed", "v1.0", 1)
rows = build_grouped_report([(issue, 1.0)], status_translation=None)
assert rows[0]["status_ru"] == "Закрыто"