31 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
3ce4f408ed chore: bump version to 2.0.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-09-30 12:37:00 +07:00
Кокос Артем Николаевич
27d21ef639 refactor: период через period.from вместо last_used; дедуп удалён
Удалены last_used, precision, dynamic и дедупликация по created_on/updated_on. Период задаётся одним полем period.from (env FROM_DATE), окно -- от этой даты до сегодня. После успешного --commit/--send начало окна сдвигается на конец отчёта; datetime-границы --date приводятся к полным дням. Схема конфига ломающая: старые ключи игнорируются, миграция описана в документации.
2026-09-30 12:37:00 +07:00
Кокос Артем Николаевич
db069dfb4b chore: bump version to 1.12.1
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-09-15 15:43:11 +07:00
Кокос Артем Николаевич
4a9a21624d fix: sliding period window for datetime precision
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
After --commit with precision=datetime, last_used held from==to==now,
so the next default period degenerated to a single second and showed
no new time entries. Now --commit stores the real report window start
and get_default_date_range() returns last_used.to..now for
datetime+dynamic mode.

Closes #70
2026-09-15 15:40:20 +07:00
Кокос Артем Николаевич
35fa585bd0 feat: add email confirmation prompt before sending report
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
Require user confirmation (y/N) before sending email via --send.
Shows from/to/cc/bcc/file info. Skips send on anything except y/yes.

Version bump: 1.11.1 → 1.12.0
2026-07-26 16:15:10 +07:00
Кокос Артем Николаевич
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
21 changed files with 2570 additions and 1213 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

311
README.md
View File

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

View File

@@ -1,5 +1,32 @@
# Настройка redmine-reporter # Настройка redmine-reporter
## Оглавление
- [Источники конфигурации](#источники-конфигурации)
- [YAML-конфиг](#yaml-конфиг)
- [Структура](#структура)
- [`redmine.verify_ssl` — проверка TLS-сертификата](#redmineverify_ssl--проверка-tls-сертификата)
- [`period.from` — начало окна отчёта](#periodfrom--начало-окна-отчёта)
- [`--date` — формат диапазона](#--date--формат-диапазона)
- [Авто-сдвиг начала периода](#авто-сдвиг-начала-периода)
- [Миграция старых ключей периода](#миграция-старых-ключей-периода)
- [`email` — настройка отправки по почте](#email--настройка-отправки-по-почте)
- [`--send` — отправка отчёта по email](#--send--отправка-отчёта-по-email)
- [`output` — путь и имя файла по умолчанию](#output--путь-и-имя-файла-по-умолчанию)
- [Подстановка переменных окружения](#подстановка-переменных-окружения)
- [`report` — настройки содержимого отчёта](#report--настройки-содержимого-отчёта)
- [`report.status_translation` — перевод статусов](#reportstatus_translation--перевод-статусов)
- [Разрешение выходного пути](#разрешение-выходного-пути)
- [Миграция с `.env` на YAML](#миграция-с-env-на-yaml)
- [Быстрый старт](#быстрый-старт)
- [Что делает `--init-config`](#что-делает---init-config)
- [Флаги миграции](#флаги-миграции)
- [Проверка после миграции](#проверка-после-миграции)
- [Сосуществование `.env` и YAML](#сосуществование-env-и-yaml)
- [Откат](#откат)
- [`.env` (legacy)](#env-legacy)
- [Безопасность](#безопасность)
## Источники конфигурации ## Источники конфигурации
Приоритет, от высшего к низшему: Приоритет, от высшего к низшему:
@@ -8,6 +35,9 @@
CLI-флаги > переменные окружения > .env > YAML > кодовые дефолты CLI-флаги > переменные окружения > .env > YAML > кодовые дефолты
``` ```
Нюанс с `override` при автозагрузке `.env` и `--config` — см.
[Сосуществование `.env` и YAML](#сосуществование-env-и-yaml).
Если значение не задано на верхнем уровне, берётся уровень ниже. `.env` и YAML Если значение не задано на верхнем уровне, берётся уровень ниже. `.env` и YAML
работают одновременно — можно оставить оба, можно удалить `.env` после миграции. работают одновременно — можно оставить оба, можно удалить `.env` после миграции.
@@ -30,14 +60,7 @@ redmine:
verify_ssl: true verify_ssl: true
period: period:
precision: date from: "2026-06-01"
default_from: "2026-06-01"
# default_to можно не указывать — тогда конец периода будет сегодня
default_to: "2026-06-30"
dynamic: false
last_used:
from: "2026-06-30T09:00:00"
to: "2026-06-30T12:00:00"
output: output:
dir: ~/reports dir: ~/reports
@@ -65,66 +88,128 @@ email:
attach: true attach: true
``` ```
### `period.precision` — точность периода ### `redmine.verify_ssl` — проверка TLS-сертификата
Определяет, как вычисляется следующий период после фиксации: Управляет проверкой TLS-сертификата Redmine. Семантика едина с переменной
окружения `REDMINE_VERIFY`:
- `date` (по умолчанию) — период с точностью до дня. Следующий запуск (после `--commit`) начинается со следующего дня. | Значение | Поведение |
- `datetime` — период с точностью до секунды. При повторном запуске time entries с `created_on` и `updated_on` ранее `last_used.to` исключаются (AND-логика: запись исключается только если **оба** поля раньше cutoff). Это предотвращает дублирование при отправке отчёта внутри рабочего дня. |---|---|
| `true` (по умолчанию) | Проверка по системному хранилищу CA операционной системы (через truststore) — корпоративные CA, добавленные в ОС, работают без настройки |
| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM |
| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть |
`last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется. До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь
`/etc/ssl/certs/ca-certificates.crt`, который существует только в
Debian/Ubuntu. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
одинаково — оба включают проверку по системному хранилищу CA на любой ОС
(через truststore), без привязки к конкретному пути.
### `period.default_to` — необязательное окончание периода ### `period.from` — начало окна отчёта
Если `period.default_to` не задан, а `period.default_from` задан, инструмент Единственный ключ периода в YAML. Дата в формате `YYYY-MM-DD`:
использует сегодняшнюю дату в качестве конца периода.
```yaml ```yaml
period: period:
default_from: "2026-07-01" from: "2026-06-01"
# default_to отсутствует → конец периода = сегодня
``` ```
Это предотвращает устаревание периода, когда отчёт генерируется автоматически Окно отчёта — `from..сегодня`: конец окна всегда сегодняшняя дата и отдельно
(`--send`, `--commit`) без явного `--date`. не настраивается. Переменная окружения `FROM_DATE` задаёт то же начало и имеет
приоритет над YAML:
Аналогично работает `DEFAULT_TO_DATE`: если переменная не задана, а ```
`DEFAULT_FROM_DATE` задана, конец периода = сегодня. --date > FROM_DATE (env) > period.from (YAML) > 1-е число текущего месяца
```
### `--commit` — автофиксация периода Разрешение начала окна:
Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода. | Условие | Начало окна |
|---|---|
| `from` не задан (`FROM_DATE` и `period.from` пусты) | 1-е число текущего месяца |
| `from` — корректная дата | `from` |
| `from` позже сегодняшней даты | сегодня (окно «сегодня--сегодня») |
| `from` некорректна (не `YYYY-MM-DD`) | warning в stderr + 1-е число текущего месяца |
**Что делает:** Граничный день (день коммита) становится началом следующего окна и потому
перечитывается: уже отправленные записи этого дня повторятся в следующем
отчёте. Это осознанное следствие отсутствия дедупликации; зато поздние
записи, добавленные в Redmine в день коммита после отправки, не теряются.
Повторный запуск `--commit`/`--send` в тот же день даёт окно
«сегодня--сегодня».
1. Генерирует отчёт как обычно. `period.from` записывается автоматически при `--commit`/`--send`; вручную
2. Сохраняет отчёт в файл: редактировать не требуется.
- Если указан `--output` — по явному пути.
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
3. Записывает `period.last_used.from` / `period.last_used.to` в YAML-конфиг.
4. При `period.precision: datetime` сохраняет текущий момент времени (ISO с секундами).
5. При `period.precision: date` сохраняет даты периода.
**Поведение `period.dynamic`:** ### `--date` — формат диапазона
- `dynamic: true` — следующий запуск (без `--date`) вычисляет период от `last_used`: Флаг `--date` задаёт диапазон явно и переопределяет все остальные источники.
- Полный календарный месяц → следующий полный месяц. Каждая граница — дата `YYYY-MM-DD` или datetime `YYYY-MM-DDTHH:MM:SS`:
- Произвольный диапазон → та же длительность, начиная со дня после `last_used.to`.
- `dynamic: false` — `--commit` перезаписывает `default_from`/`default_to` на использованный период.
**Примеры:** - `2026-06-01--2026-06-30`;
- `2026-06-30T09:00:00--2026-06-30T12:00:00`.
Время в границах отбрасывается — окно всегда состоит из полных дней, так как
гранулярность Redmine — день. Границы можно смешивать (дата + datetime).
Начало позже конца (`start > end`) — ошибка.
### Авто-сдвиг начала периода
`--commit` и `--send` генерируют отчёт, сохраняют его в файл (по `--output`
или по шаблону `output.dir`/`output.filename`) и сдвигают начало окна.
`--send` дополнительно отправляет отчёт по email (см.
[ниже](#--send--отправка-отчёта-по-email)).
После успешного `--commit` (файл сохранён) или реальной отправки `--send`
(подтверждение «y» + успешная отправка) в конфиг пишется
`period.from = <конец окна отчёта>`. Следующий запуск без `--date` начинает
окно с граничного дня последнего отчёта.
Сдвиг НЕ происходит:
- если за период нет time entries (отчёт пуст — выход до сохранения);
- при отказе от подтверждения отправки в `--send` (ответ не «y»/«yes»);
- при ошибке SMTP в `--send` (соединение, аутентификация, таймаут);
- при ошибке сохранения файла отчёта.
Исключение: при явном `--commit` (сочетание `--send --commit`) отказ от
подтверждения отправки сдвиг не отменяет: окно всё равно сдвигается по
`--commit`.
При сочетании `--send --commit` ошибка SMTP подавляет и сдвиг по `--commit`:
файл остаётся сохранён, но начало периода не двигается.
Примеры:
```bash ```bash
# Июнь 2026 → следующий запуск (без --date) → июль 2026 # Окно 1--30 июня → следующий запуск (без --date) начнётся с 30 июня
redmine-reporter --date 2026-06-01--2026-06-30 --commit redmine-reporter --date 2026-06-01--2026-06-30 --commit
# Произвольный диапазон: 15-20 июня → следующий запуск → 21-26 июня # Произвольный диапазон 15--20 июня → следующее окно с 20 июня
redmine-reporter --date 2026-06-15--2026-06-20 --commit redmine-reporter --date 2026-06-15--2026-06-20 --commit
# С datetime-точностью: повторный запуск не дублирует записи # Отправить и сдвинуть окно (только при подтверждении «y» и успехе)
redmine-reporter --commit redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
``` ```
`--commit`/`--send` перезаписывают только `period.from`; остальные секции и
ключи YAML сохраняются. Файл перезаписывается через `yaml.dump` —
пользовательские комментарии и ручное форматирование теряются.
### Миграция старых ключей периода
Старые ключи периода больше не читаются и тихо игнорируются при загрузке —
их можно удалить из YAML:
- `period.last_used` (включая `from`/`to`);
- `period.precision`;
- `period.dynamic`;
- `period.default_from` / `period.default_to`.
Env `DEFAULT_FROM_DATE` / `DEFAULT_TO_DATE` заменены на `FROM_DATE`; конец
периода больше не настраивается (всегда сегодня). Дедупликация time entries
по `created_on`/`updated_on` удалена.
### `email` — настройка отправки по почте ### `email` — настройка отправки по почте
Секция `email` используется флагом `--send`. Если секция не настроена или `smtp.host` Секция `email` используется флагом `--send`. Если секция не настроена или `smtp.host`
@@ -193,8 +278,10 @@ email:
### `--send` — отправка отчёта по email ### `--send` — отправка отчёта по email
Флаг `--send` отправляет сгенерированный отчёт через SMTP сразу после сохранения Флаг `--send` сохраняет отчёт в файл, отправляет его через SMTP и сдвигает
в файл. Требует настроенную секцию `email` в YAML-конфиге. начало периода при подтверждении и успешной отправке (см.
[Авто-сдвиг](#авто-сдвиг-начала-периода)). Требует настроенную
секцию `email` в YAML-конфиге.
**Что делает:** **Что делает:**
@@ -202,10 +289,14 @@ email:
2. Сохраняет отчёт в файл: 2. Сохраняет отчёт в файл:
- Если указан `--output` — по явному пути. - Если указан `--output` — по явному пути.
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`. - Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
3. Формирует MIME-письмо: 3. Запрашивает подтверждение: «Отправить? [y/N]». Ответ «y»/«yes» продолжает,
иначе отправка отменяется (без сдвига периода).
4. Формирует MIME-письмо:
- Тема, plain-text тело и вложение (если `attach: true`). - Тема, plain-text тело и вложение (если `attach: true`).
- При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта. - При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта.
4. Отправляет через SMTP с TLS (таймаут 30 секунд). 5. Отправляет через SMTP (таймаут 30 секунд). STARTTLS включается только при
`email.smtp.tls: true`; аутентификация — только если задан `email.smtp.user`.
6. При успешной отправке сдвигает начало периода.
**Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся **Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся
на диске, данные не теряются. на диске, данные не теряются.
@@ -228,7 +319,7 @@ 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 --output ~/report.xlsx --send
# Отправить и зафиксировать период # Отправить и сдвинуть начало периода
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
``` ```
@@ -238,7 +329,7 @@ redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
|---|---| |---|---|
| `--send` | Сохранить по шаблону → отправить | | `--send` | Сохранить по шаблону → отправить |
| `--send --output X` | Сохранить в X → отправить | | `--send --output X` | Сохранить в X → отправить |
| `--send --commit` | Сохранить → отправить → зафиксировать период | | `--send --commit` | Сохранить → подтвердить → отправить → сдвинуть начало периода |
| `--send` с `email.html: true` | Письмо с plain-text + HTML-таблицей | | `--send` с `email.html: true` | Письмо с plain-text + HTML-таблицей |
| `--send` без `email` в конфиге | Ошибка, exit 1 | | `--send` без `email` в конфиге | Ошибка, exit 1 |
| `--send` при ошибке SMTP | Файл сохранён, ошибка в stderr, exit 1 | | `--send` при ошибке SMTP | Файл сохранён, ошибка в stderr, exit 1 |
@@ -307,6 +398,7 @@ email:
| Поле | Тип | По умолчанию | Описание | | Поле | Тип | По умолчанию | Описание |
|---|---|---|---| |---|---|---|---|
| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта | | `no_time` | bool | `false` | Не включать затраченное время в файл отчёта |
| `status_translation` | map[str,str] | `{}` | Переопределение/дополнение перевода статусов |
`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`). `report.no_time` применяется только в автоматических режимах (`--commit`, `--send`).
При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`. При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`.
@@ -330,6 +422,22 @@ redmine-reporter --output report.odt
redmine-reporter --output report.odt --no-time redmine-reporter --output report.odt --no-time
``` ```
### `report.status_translation` — перевод статусов
По умолчанию статусы Redmine переводятся встроенным словарём
(`New` → `В работе`, `Closed` → `Закрыто` и т.д.). Секция
`report.status_translation` переопределяет отдельные переводы и/или
добавляет новые статусы; не указанные здесь статусы переводятся
встроенным словарём, а совсем неизвестные выводятся как есть.
```yaml
report:
status_translation:
"New": "Новая"
"Wait Release": "Ожидает релиза"
"Custom Status": "Кастомный статус"
```
## Разрешение выходного пути ## Разрешение выходного пути
Функция `resolve_output_path()` определяет итоговый путь к файлу: Функция `resolve_output_path()` определяет итоговый путь к файлу:
@@ -338,7 +446,7 @@ redmine-reporter --output report.odt --no-time
2. `--output xlsx` (bare format) → путь формируется как `output.dir / output.filename` с подстановкой `{ext}` = bare format и дат из периода. 2. `--output xlsx` (bare format) → путь формируется как `output.dir / output.filename` с подстановкой `{ext}` = bare format и дат из периода.
3. `--output /path/report` (без расширения) → дописывается `.output.default_format`. 3. `--output /path/report` (без расширения) → дописывается `.output.default_format`.
4. `--output /path/report.csv` (с расширением) → используется как есть. 4. `--output /path/report.csv` (с расширением) → используется как есть.
5. `--output /path/report.xyz` (неизвестное расширение) → используется как есть, форматтер выбирается по расширению. 5. `--output /path/report.xyz` (неизвестное расширение) → ошибка «Неизвестный формат файла», exit 1.
## Миграция с `.env` на YAML ## Миграция с `.env` на YAML
@@ -358,7 +466,8 @@ vim ~/.config/redmine-reporter/config.yml
### Что делает `--init-config` ### Что делает `--init-config`
- Читает текущие значения из `.env` и переменных окружения. - Читает текущие значения из `.env` и переменных окружения.
- Формирует YAML со всеми секциями (`redmine`, `period`, `output`, `email`). - Формирует YAML со всеми секциями (`redmine`, `period`, `output`, `report`, `email`).
- В конец файла дописывает закомментированный пример `report.status_translation`.
- Секреты (`REDMINE_API_KEY`, `SMTP_PASSWORD`) записывает как `${VAR}`, если - Секреты (`REDMINE_API_KEY`, `SMTP_PASSWORD`) записывает как `${VAR}`, если
переменная существует, иначе — пустая строка. переменная существует, иначе — пустая строка.
- Создаёт файл с правами `0600`, директорию — с `0700`. - Создаёт файл с правами `0600`, директорию — с `0700`.
@@ -369,11 +478,10 @@ vim ~/.config/redmine-reporter/config.yml
|---|---| |---|---|
| `--init-config` | Создать YAML и выйти | | `--init-config` | Создать YAML и выйти |
| `--init-config --force` | Перезаписать существующий YAML | | `--init-config --force` | Перезаписать существующий YAML |
| `--config-path PATH` | Сохранить YAML по указанному пути (по умолчанию `~/.config/redmine-reporter/config.yml`) | | `--config-path PATH` | Путь к YAML-конфигу: загрузка при запуске, запись при `--init-config` и `--commit` (по умолчанию `~/.config/redmine-reporter/config.yml`) |
Если `DEFAULT_TO_DATE` не задана, а `DEFAULT_FROM_DATE` задана, сгенерированный Если задана `FROM_DATE`, сгенерированный YAML будет содержать `period.from` с
YAML будет содержать пустое `default_to`, и при запуске инструмент использует этим значением.
сегодняшнюю дату.
### Проверка после миграции ### Проверка после миграции
@@ -401,6 +509,10 @@ ls -la ~/.config/redmine-reporter/
YAML работает как базовый слой для всего, что не в .env YAML работает как базовый слой для всего, что не в .env
``` ```
Автозагрузка `.env` (без `--config`) не перебивает реальные переменные
окружения (`override=False`). Исключение — `--config FILE`: указанный `.env`
загружается с `override=True` и перебивает переменные окружения (но не CLI-флаги).
Это safe — если с YAML что-то пойдёт не так, просто положи `.env` обратно. Это safe — если с YAML что-то пойдёт не так, просто положи `.env` обратно.
### Откат ### Откат
@@ -411,18 +523,19 @@ rm ~/.config/redmine-reporter/config.yml
## `.env` (legacy) ## `.env` (legacy)
Для обратной совместимости `.env` продолжает работать без изменений. Для обратной совместимости `.env` продолжает работать как раньше, кроме ключей
периода: `DEFAULT_FROM_DATE` заменён на `FROM_DATE`, `DEFAULT_TO_DATE` удалён
(см. [Миграция старых ключей периода](#миграция-старых-ключей-периода)).
```ini ```ini
REDMINE_URL=https://red.eltex.loc REDMINE_URL=https://red.eltex.loc
REDMINE_API_KEY=your-api-key REDMINE_API_KEY=your-api-key
REDMINE_AUTHOR=Кокос А.А. REDMINE_AUTHOR=Кокос А.А.
DEFAULT_FROM_DATE=2026-06-01 FROM_DATE=2026-06-01
DEFAULT_TO_DATE=2026-06-30
``` ```
Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц
как период, стандартный путь сертификатов, пустой автор). как период, стандартная проверка TLS, пустой автор).
## Безопасность ## Безопасность

313
docs/USER_GUIDE.md Normal file
View File

@@ -0,0 +1,313 @@
# Руководство пользователя redmine-reporter
## Оглавление
- [Установка](#установка)
- [Первоначальная настройка](#первоначальная-настройка)
- [Выбор периода](#выбор-периода)
- [Отчёт за другого пользователя](#отчёт-за-другого-пользователя)
- [Экспорт в файл](#экспорт-в-файл)
- [Содержимое отчёта](#содержимое-отчёта)
- [Отправка по email](#отправка-по-email)
- [Скользящее окно периода](#скользящее-окно-периода)
- [Справочник флагов](#справочник-флагов)
- [Устранение неполадок](#устранение-неполадок)
- [См. также](#см-также)
## Установка
Требуется Python >= 3.10.
```bash
git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git
cd redmine-reporter
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install .
```
Проверка:
```bash
redmine-reporter --version
```
Перед каждым запуском активируйте окружение: `source .venv/bin/activate`.
## Первоначальная настройка
1. Убедитесь, что в текущей директории есть `.env` с `REDMINE_URL` и `REDMINE_API_KEY` (или задайте эти переменные в окружении).
2. Сгенерируйте YAML-конфиг:
```bash
redmine-reporter --init-config
```
Результат: файл `~/.config/redmine-reporter/config.yml` (права `0600`, директория `0700`) со всеми секциями и закомментированным примером `report.status_translation`. Секреты, заданные в окружении, записываются как `${VAR}`.
3. Если файл уже существует — ошибка; для перезаписи добавьте `--force`:
```bash
redmine-reporter --init-config --force
```
4. Нестандартное расположение конфига:
```bash
redmine-reporter --init-config --config-path /etc/redmine-reporter/config.yml
```
`--config-path` задаёт путь и для загрузки YAML, и для записи при `--init-config` и `--commit`.
5. Откройте файл и заполните минимум: `redmine.url`, `redmine.api_key`, `redmine.author`.
`--init-config` несовместим с флагами отчёта (`--date`, `--output`, `--compact`, `--summary`, `--user-id`/`--user-login`/`--user-name`, `--no-time`, `--by-activity`, `--send`) — указывайте его отдельно.
Полный референс всех секций и переменных: [CONFIG.md](CONFIG.md).
## Выбор периода
Окно отчёта — диапазон дат `from..to`. Конец (`to`) всегда сегодняшняя дата,
если диапазон не задан явно через `--date`.
Приоритет начала окна (от высшего к низшему):
1. `--date` — явный диапазон (полностью переопределяет окно).
2. `FROM_DATE` (env) — начало; конец — сегодня.
3. `period.from` (YAML) — начало; конец — сегодня.
4. Ничего не задано — текущий месяц: с 1-го числа по сегодня.
Форматы `--date`:
```bash
# Даты
redmine-reporter --date 2026-06-01--2026-06-30
# Дата-время (границы флорятся до полных дней)
redmine-reporter --date 2026-06-01T09:00:00--2026-06-01T18:00:00
```
Границы с временем округляются до дат — отчёт строится за полные дни диапазона.
Границы можно смешивать (дата + дата-время). Начало позже конца
(`start > end`) — ошибка.
Краевые случаи:
- `from` позже сегодняшней даты — окно «сегодня--сегодня».
- `from` некорректна (не `YYYY-MM-DD`) — warning + текущий месяц.
## Отчёт за другого пользователя
Без флагов отчёт строится за текущего пользователя.
```bash
redmine-reporter --user-id 42
redmine-reporter --user-login ivanov
redmine-reporter --user-name "Иванов Иван Иванович"
```
Правила:
- Флаги `--user-id`, `--user-login`, `--user-name` взаимоисключающие: укажите только один, иначе ошибка.
- Все три принимают одно значение. Число или строка из цифр → поиск по ID. Иначе — точное регистрозависимое совпадение логина.
- Если по логину совпадений нет — поиск по имени. Одно совпадение → пользователь найден.
- Несколько совпадений по имени → ошибка «Multiple users match...» со списком логинов (при дублях логина — ID).
- Ноль совпадений → ошибка «User '...' not found...».
## Экспорт в файл
Флаг `--output` определяется по четырём правилам:
| Аргумент | Результат |
| --- | --- |
| `--output xlsx` (bare-формат: `xlsx`, `odt`, `csv`, `md`, `html`, `json`, регистронезависимо) | Путь = `output.dir` + шаблон `output.filename` из YAML, расширение = указанный формат |
| `--output /tmp/report` (путь без расширения) | Дописывается `.` + `output.default_format` → `/tmp/report.xlsx` |
| `--output /tmp/report.csv` (путь с любым расширением) | Используется как есть |
| `--output /tmp/report.xyz` (неизвестное расширение) | Ошибка «Неизвестный формат файла: '.xyz'. Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx», exit 1 |
Формат `.odt` требует установленного пакета `odfpy` (устанавливается по умолчанию) — иначе отдельная ошибка.
Примеры:
```bash
redmine-reporter --output xlsx # по шаблону в output.dir
redmine-reporter --output 2026-06-report # → 2026-06-report.xlsx (если default_format: xlsx)
redmine-reporter --output reports/june.csv # как есть
```
Плейсхолдеры `output.filename`:
| Плейсхолдер | Описание | Пример |
| --- | --- | --- |
| `{author}` | Имя автора, пробелы → `_` | `Кокос_А.А.` |
| `{from}` | Начало периода, `YYYY-MM-DD` | `2026-06-01` |
| `{to}` | Конец периода, `YYYY-MM-DD` | `2026-06-30` |
| `{date}` | Конец периода, `DD_MM_YYYY` | `30_06_2026` |
| `{ext}` | Расширение без точки | `xlsx` |
Неизвестные плейсхолдеры остаются в имени как есть.
Нюансы с путями:
- `~` в явном `--output` раскрывает шелл, а не программа: `redmine-reporter --output ~/report.xlsx` работает, а в кавычках (`"~/report.xlsx"`) — нет.
- `expanduser` применяется только к `output.dir` из YAML.
## Содержимое отчёта
### `--no-time`
Убирает затраченное время из отчёта. Физически удаляет колонку времени только CSV. XLSX, HTML, ODT, Markdown, JSON и консольный вывод оставляют пустые колонки/поля; в XLSX дополнительно пропадают строки итогов по версиям/проектам/всего.
```bash
redmine-reporter --output report.xlsx --no-time
```
YAML-настройка `report.no_time` действует только в автоматических режимах (`--commit`, `--send`). При ручном `--output` работает только CLI-флаг.
### `--by-activity`
Разбивка затраченного времени по типам активности. В JSON-отчёте добавляется поле `activities`.
```bash
redmine-reporter --by-activity
```
### `--summary`
Печатает сводку в stderr (после отчёта):
- `total` — суммарное время;
- `project:*` — время по каждому проекту;
- `activity:*` — время по каждой активности (только при одновременном `--by-activity`).
Версии в сводку не входят.
```bash
redmine-reporter --by-activity --summary
```
### `--compact`
Компактный текстовый вывод в консоль вместо таблицы:
```bash
redmine-reporter --compact
```
### Перевод статусов
Статусы задач переводятся встроенным словарём; секция `report.status_translation` переопределяет или дополняет переводы. Подробно: [CONFIG.md](CONFIG.md).
## Отправка по email
Требуется секция `email` в YAML-конфиге (см. [CONFIG.md](CONFIG.md)).
```bash
redmine-reporter --date 2026-06-01--2026-06-30 --send
redmine-reporter --date 2026-06-01--2026-06-30 --output report.xlsx --send
redmine-reporter --send --commit
```
Факты:
- Файл отчёта сохраняется на диск ДО отправки: при ошибке SMTP файл остаётся на месте.
- Перед отправкой запрашивается подтверждение «Отправить? [y/N]»; ответ «y»/«yes» продолжает, иначе отправка отменяется (без сдвига периода, если не задан явный `--commit`).
- При успешной отправке начало периода сдвигается (см. [Скользящее окно периода](#скользящее-окно-периода)); при отмене (если не задан явный `--commit`) или ошибке SMTP — без сдвига.
- Секция `email` отсутствует или `smtp.host` пуст → ошибка «Email не настроен. Добавьте секцию 'email' в конфиг...», exit 1.
- `email.html: true` → письмо `multipart/alternative`: plain-text + HTML-таблица отчёта в теле.
- Файл прикрепляется при `attach: true`; MIME-тип определяется по расширению, неизвестное → `application/octet-stream`.
- Получатели `bcc` указываются только в envelope (не видны в заголовках письма).
- STARTTLS применяется только при `smtp.tls: true`; аутентификация на SMTP-сервере — только если задан `smtp.user`.
- Таймаут соединения с SMTP — 30 секунд.
Точные тексты ошибок:
- «Ошибка аутентификации SMTP. Проверьте логин и пароль.»
- «Таймаут соединения с SMTP-сервером.»
- «Ошибка отправки письма: ...»
- «Не удалось подключиться к SMTP-серверу host:port»
## Скользящее окно периода
После успешного `--commit` (файл сохранён) или реальной отправки `--send`
(подтверждение «y» + успех) окно отчёта сдвигается вперёд: в конфиг пишется
`period.from = <конец окна>`. Следующий запуск без `--date`
начинается с граничного дня последнего отчёта.
Типичный цикл:
```bash
# 1. Отчёт за июнь 2026: сохранить, отправить, сдвинуть начало на 30 июня
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
# 2. Следующий запуск без --date берёт окно с 30 июня по сегодня
redmine-reporter --send --commit
```
Граничный день: начало следующего окна совпадает с днём коммита, поэтому этот
день перечитывается. Записи дня коммита, уже попавшие в прошлый отчёт,
повторятся (дедупликации по `created_on`/`updated_on` нет — это осознанное
следствие), зато поздние записи, добавленные в Redmine в этот день после
отправки, не теряются.
Повторный запуск `--commit`/`--send` в тот же день даёт окно
«сегодня--сегодня», которое повторно включает уже отправленные записи этого
дня.
Важно: `--commit`/`--send` перезаписывают только `period.from`; остальные
ключи YAML сохраняются, но пользовательские комментарии теряются
(перезапись через `yaml.dump`).
## Справочник флагов
| Флаг | Описание |
| --- | --- |
| `--date` | Явный диапазон: границы — дата `YYYY-MM-DD` или datetime `YYYY-MM-DDTHH:MM:SS`. Время отбрасывается (полные дни), границы можно смешивать; `start > end` — ошибка. |
| `--compact` | Компактный текстовый вывод в консоль вместо таблицы. |
| `--output` | Путь к файлу (`.odt`/`.csv`/`.md`/`.html`/`.json`/`.xlsx`) или bare-формат — тогда путь берётся из YAML-шаблона; без флага — вывод в консоль (stdout). |
| `--author` | Переопределить имя автора отчёта. |
| `--no-time` | Не включать затраченное время в отчёт. |
| `--url` | Переопределить URL Redmine. |
| `--api-key` | Переопределить API-ключ Redmine. |
| `--config` | Путь к альтернативному `.env`-файлу. |
| `--verbose` | Подробный вывод; показывает traceback при ошибках. |
| `--debug` | Отладочный вывод; показывает traceback при ошибках. |
| `--version` | Показать версию и выйти. |
| `--summary` | Сводка по времени в stderr: `total`, `project:*`, `activity:*` (версии не печатаются). |
| `--user-id` | Отчёт за пользователя по ID. |
| `--user-login` | Отчёт за пользователя по логину (точное регистрозависимое совпадение). |
| `--user-name` | Отчёт за пользователя по имени. |
| `--by-activity` | Разбивка времени по типам активности (в JSON — поле `activities`). |
| `--init-config` | Сгенерировать YAML-конфиг из текущего `.env`/окружения и выйти. |
| `--force` | Перезаписать существующий YAML-конфиг (только с `--init-config`). |
| `--config-path` | Путь к YAML-конфигу: загрузка, запись `--init-config` и `--commit`. По умолчанию `~/.config/redmine-reporter/config.yml`. |
| `--commit` | Сохранить отчёт в файл и сдвинуть начало периода (`period.from` = конец окна) в YAML. |
| `--send` | Отправить отчёт по email после сохранения файла; при подтверждении и успехе сдвинуть начало периода. |
## Устранение неполадок
| Ошибка | Причина | Решение |
| --- | --- | --- |
| «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] [project]
name = "redmine-reporter" name = "redmine-reporter"
version = "1.10.1" version = "2.0.0"
description = "Redmine time-entry based issue reporter for internal use" description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md" readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
license = { text = "MIT" } license = { text = "MIT" }
classifiers = [ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Operating System :: POSIX :: Linux", "Operating System :: POSIX :: Linux",
"Environment :: Console", "Environment :: Console",
] ]
requires-python = ">=3.9" requires-python = ">=3.10"
dependencies = [ dependencies = [
"python-redmine>=2.4.0", "python-redmine>=2.4.0",
"tabulate>=0.9.0", "tabulate>=0.9.0",
@@ -25,6 +24,9 @@ dependencies = [
"odfpy>=1.4.0", "odfpy>=1.4.0",
"openpyxl>=3.1.0", "openpyxl>=3.1.0",
"pyyaml>=6.0", "pyyaml>=6.0",
"requests>=2.31",
"truststore>=0.10",
"urllib3>=1.26",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -32,6 +34,9 @@ dev = [
"pytest>=7.0", "pytest>=7.0",
"mypy>=1.0", "mypy>=1.0",
"ruff>=0.1.0", "ruff>=0.1.0",
"black>=24.0",
"isort>=5.0",
"setuptools>=61.0",
] ]
[project.scripts] [project.scripts]
@@ -44,9 +49,16 @@ include = ["redmine_reporter*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
"redmine_reporter" = ["templates/template.odt"] "redmine_reporter" = ["templates/template.odt"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.mypy] [tool.mypy]
warn_unused_configs = true warn_unused_configs = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"] module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"]
ignore_missing_imports = true ignore_missing_imports = true
[tool.isort]
profile = "black"

View File

@@ -1 +1 @@
__version__ = "1.10.1" __version__ = "2.0.0"

View File

@@ -3,11 +3,12 @@ import logging
import os import os
import re import re
import sys import sys
from datetime import datetime, timezone from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
import yaml import yaml
from dotenv import find_dotenv, load_dotenv
from . import __version__ from . import __version__
from .client import RedmineAPIError, fetch_issues_with_spent_time from .client import RedmineAPIError, fetch_issues_with_spent_time
@@ -17,33 +18,72 @@ from .mailer import send_report
from .report_builder import build_grouped_report, calculate_summary from .report_builder import build_grouped_report, calculate_summary
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
# Маскируем API-ключ Redmine в тексте ошибок (#55):
# - query-параметр key=<значение> в URL (?key=... / &key=...);
# - заголовок X-Redmine-API-Key: <значение>.
_SANITIZE_PATTERNS = [
(re.compile(r"([?&]key=)[^\s&\"')]+", re.IGNORECASE), r"\1***"),
(
re.compile(
r"(X-Redmine-API-Key[\"']?\s*[:=]\s*[\"']?)[^\s\"',}]+", re.IGNORECASE
),
r"\1***",
),
]
def _sanitize_error_text(text: str) -> str:
"""Маскирует API-ключ Redmine в тексте ошибки перед выводом в stderr (#55)."""
for pattern, replacement in _SANITIZE_PATTERNS:
text = pattern.sub(replacement, text)
return text
def _parse_boundary(value: str) -> datetime:
"""Разбирает одну границу диапазона как дату или datetime без timezone."""
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
fmt = "%Y-%m-%d"
elif re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", value):
fmt = "%Y-%m-%dT%H:%M:%S"
else:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try:
return datetime.strptime(value, fmt)
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
def parse_date_range(date_arg: str) -> tuple[str, str]: def parse_date_range(date_arg: str) -> tuple[str, str]:
"""Возвращает границы диапазона в виде дат YYYY-MM-DD.
Каждая граница может быть датой или datetime; время отбрасывается
(границы окна -- целые дни), поэтому смешение форматов допустимо.
"""
if "--" not in date_arg: if "--" not in date_arg:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
parts = date_arg.split("--", 1) parts = date_arg.split("--", 1)
from_date, to_date = parts[0].strip(), parts[1].strip() from_date, to_date = parts[0].strip(), parts[1].strip()
date_pattern = r"\d{4}-\d{2}-\d{2}" start = _parse_boundary(from_date)
if not re.fullmatch(date_pattern, from_date) or not re.fullmatch( end = _parse_boundary(to_date)
date_pattern, to_date
):
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try: if start.date() > end.date():
start = datetime.strptime(from_date, "%Y-%m-%d").date()
end = datetime.strptime(to_date, "%Y-%m-%d").date()
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
if start > end:
raise ValueError("Date range start must be less than or equal to end") raise ValueError("Date range start must be less than or equal to end")
return start.isoformat(), end.isoformat() return start.date().isoformat(), end.date().isoformat()
def _run_init_config(config_path: str, force: bool) -> int: def _run_init_config(config_path: str, force: bool) -> int:
"""Создаёт YAML-конфиг из текущих настроек окружения.""" """Создаёт YAML-конфиг из текущих настроек окружения.
Подгружает .env из текущей директории (override=False: реальные
переменные окружения не перебиваются), чтобы --init-config видел
значения, заданные только в .env (#64). Поиск идёт от cwd
(usecwd=True): find_dotenv() без него стартует от директории
cli.py и .env пользователя не находит.
"""
load_dotenv(find_dotenv(usecwd=True), override=False)
path = Path(config_path) path = Path(config_path)
if path.exists() and not force: if path.exists() and not force:
@@ -62,10 +102,7 @@ def _run_init_config(config_path: str, force: bool) -> int:
"verify_ssl": True, "verify_ssl": True,
}, },
"period": { "period": {
"precision": "date", "from": os.getenv("FROM_DATE", "").strip(),
"default_from": os.getenv("DEFAULT_FROM_DATE", "").strip(),
"default_to": os.getenv("DEFAULT_TO_DATE", "").strip(),
"dynamic": False,
}, },
"output": { "output": {
"dir": "", "dir": "",
@@ -99,6 +136,16 @@ def _run_init_config(config_path: str, force: bool) -> int:
yaml.dump( yaml.dump(
data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
) )
fh.write(
"\n"
"# report.status_translation: переопределение/дополнение перевода\n"
"# статусов Redmine. Без секции используется встроенный словарь.\n"
"# Пример:\n"
"# report:\n"
"# status_translation:\n"
'# "New": "Новая"\n'
'# "Wait Release": "Ожидает релиза"\n'
)
path.chmod(0o600) path.chmod(0o600)
sections_found = [s for s in data if data[s]] sections_found = [s for s in data if data[s]]
@@ -108,29 +155,6 @@ def _run_init_config(config_path: str, force: bool) -> int:
return 0 return 0
def _compute_dedup_cutoff() -> Optional[datetime]:
"""Compute deduplication cutoff from config.
If period.precision == 'datetime' and period.last_used.to is set,
return the datetime to filter out entries already accounted for.
Otherwise return None.
"""
if Config.get_period_precision() != "datetime":
return None
last_to = Config.get_last_used_to()
if not last_to:
return None
try:
dt = datetime.fromisoformat(last_to.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except (ValueError, TypeError):
return None
def _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool: def _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool:
"""Возвращает финальное значение no_time. """Возвращает финальное значение no_time.
@@ -154,10 +178,11 @@ def _save_and_maybe_send(
to_date: str, to_date: str,
no_time: bool, no_time: bool,
do_send: bool, do_send: bool,
) -> int: ) -> tuple[int, bool]:
"""Сохраняет отчёт в файл и опционально отправляет по email. """Сохраняет отчёт в файл и опционально отправляет по email.
Returns 0 on success, 1 on error. Returns (exit_code, sent): sent отражает факт реальной отправки письма,
чтобы main мог отличить отказ от подтверждения от успешной отправки.
""" """
output_ext = os.path.splitext(output_arg)[1].lower() output_ext = os.path.splitext(output_arg)[1].lower()
@@ -166,7 +191,7 @@ def _save_and_maybe_send(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx", "❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1, False
formatter = get_formatter_by_extension( formatter = get_formatter_by_extension(
output_ext, output_ext,
@@ -189,7 +214,7 @@ def _save_and_maybe_send(
f"Поддерживаются: {known_exts}", f"Поддерживаются: {known_exts}",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1, False
try: try:
formatter.save(rows, output_arg) formatter.save(rows, output_arg)
@@ -197,7 +222,7 @@ def _save_and_maybe_send(
except Exception as e: except Exception as e:
fmt = output_ext.lstrip(".").upper() fmt = output_ext.lstrip(".").upper()
print(f"❌ {fmt} export error: {e}", file=sys.stderr) print(f"❌ {fmt} export error: {e}", file=sys.stderr)
return 1 return 1, False
if do_send: if do_send:
email_config = Config.get_email_config() email_config = Config.get_email_config()
@@ -207,7 +232,20 @@ def _save_and_maybe_send(
"(~/.config/redmine-reporter/config.yml).", "(~/.config/redmine-reporter/config.yml).",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1, False
print("\n📧 Готово к отправке:")
print(f" From: {email_config.from_}")
print(f" To: {', '.join(email_config.to)}")
if email_config.cc:
print(f" Cc: {', '.join(email_config.cc)}")
if email_config.bcc:
print(f" Bcc: {', '.join(email_config.bcc)}")
print(f" File: {output_arg}")
response = input("Отправить? [y/N]: ").strip().lower()
if response not in ("y", "yes"):
print("⏭️ Отправка отменена.")
return 0, False
try: try:
send_report( send_report(
@@ -219,10 +257,11 @@ def _save_and_maybe_send(
) )
print(f"📧 Report sent to {', '.join(email_config.to)}") print(f"📧 Report sent to {', '.join(email_config.to)}")
except RedmineAPIError as e: except RedmineAPIError as e:
print(f"❌ {e.message}", file=sys.stderr) print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
return 1 return 1, False
return 0, True
return 0 return 0, False
def main(argv: Optional[List[str]] = None) -> int: def main(argv: Optional[List[str]] = None) -> int:
@@ -303,12 +342,12 @@ def main(argv: Optional[List[str]] = None) -> int:
parser.add_argument( parser.add_argument(
"--commit", "--commit",
action="store_true", action="store_true",
help="Save used period as last_used in YAML config and auto-commit to file", help="Save the report and advance period start to its end date in YAML config",
) )
parser.add_argument( parser.add_argument(
"--send", "--send",
action="store_true", action="store_true",
help="Send generated report via email after saving (requires email section in config)", help="Send generated report via email after saving (requires email section in config); on success advance period start to its end date",
) )
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -382,21 +421,18 @@ def main(argv: Optional[List[str]] = None) -> int:
to_date, to_date,
user_id=args.user_id or args.user_login or args.user_name, user_id=args.user_id or args.user_login or args.user_name,
by_activity=args.by_activity, by_activity=args.by_activity,
dedup_before=_compute_dedup_cutoff(),
) )
except RedmineAPIError as e: except RedmineAPIError as e:
print(f"❌ {e.message}", file=sys.stderr) print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
if args.debug and e.original is not None: if (args.verbose or args.debug) and e.original is not None:
logging.exception("Original Redmine API error") logging.exception("Original Redmine API error")
return 1 return 1
except Exception as e: except Exception as e:
print(f"❌ Unexpected error: {e}", file=sys.stderr) print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
if args.verbose or args.debug:
logging.exception("Unexpected error")
return 1 return 1
if issue_hours is None:
print("ℹ️ No time entries found in the given period.", file=sys.stderr)
return 0
if not issue_hours: if not issue_hours:
print("ℹ️ No time entries found in the given period.", file=sys.stderr) print("ℹ️ No time entries found in the given period.", file=sys.stderr)
return 0 return 0
@@ -410,6 +446,7 @@ def main(argv: Optional[List[str]] = None) -> int:
issue_hours, issue_hours,
fill_time=not no_time, fill_time=not no_time,
by_activity=args.by_activity, by_activity=args.by_activity,
status_translation=Config.get_status_translation(),
) )
if args.summary: if args.summary:
@@ -426,6 +463,9 @@ def main(argv: Optional[List[str]] = None) -> int:
activity = key.split(":", 1)[1] activity = key.split(":", 1)[1]
print(f" [{activity}]: {value}h", file=sys.stderr) print(f" [{activity}]: {value}h", file=sys.stderr)
save_ok = False
report_sent = False
if args.output or args.commit: if args.output or args.commit:
if args.output: if args.output:
output_arg = resolve_output_path( output_arg = resolve_output_path(
@@ -456,7 +496,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
return 1 return 1
ret = _save_and_maybe_send( ret, report_sent = _save_and_maybe_send(
rows, rows,
output_arg, output_arg,
author=Config.get_author(args.author), author=Config.get_author(args.author),
@@ -467,6 +507,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
if ret != 0: if ret != 0:
return ret return ret
save_ok = True
elif args.send: elif args.send:
# --send без --output и --commit: сохраняем по шаблону и отправляем # --send без --output и --commit: сохраняем по шаблону и отправляем
@@ -487,7 +528,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
return 1 return 1
ret = _save_and_maybe_send( ret, report_sent = _save_and_maybe_send(
rows, rows,
output_arg, output_arg,
author=Config.get_author(args.author), author=Config.get_author(args.author),
@@ -498,6 +539,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
if ret != 0: if ret != 0:
return ret return ret
save_ok = True
else: else:
if args.compact: if args.compact:
@@ -516,24 +558,17 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"❌ Formatting error: {e}", file=sys.stderr) print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1 return 1
if args.commit: # Окно двигается ровно один раз за запуск: либо по явному --commit
precision = Config.get_period_precision() # (операция завершилась кодом 0), либо по фактически отправленному письму --send.
dynamic = Config._app.period_dynamic if Config._app else False should_advance = (args.commit and save_ok) or (args.send and report_sent)
if should_advance:
if precision == "datetime": # Начало следующего окна совпадает с концом текущего: граничный день
from datetime import datetime as dt_mod # перечитывается. Дубли записей дня коммита осознанны, зато поздние
# записи, добавленные в тот же день после отправки, не теряются.
now = dt_mod.now().isoformat(timespec="seconds") next_from = to_date
from_str = now
to_str = now
else:
from_str = from_date
to_str = to_date
try: try:
save_period_to_config( save_period_to_config(args.config_path, from_str=next_from)
args.config_path, from_str, to_str, precision, dynamic
)
except Exception as e: except Exception as e:
print( print(
f"❌ Не удалось сохранить период в конфиг: {e}", f"❌ Не удалось сохранить период в конфиг: {e}",
@@ -541,7 +576,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
return 1 return 1
print( print(
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}", f"📌 Period committed [{next_from}] → {args.config_path}",
file=sys.stderr, file=sys.stderr,
) )

View File

@@ -1,7 +1,8 @@
from datetime import datetime import sys
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
import requests import requests
import truststore
from redminelib import Redmine from redminelib import Redmine
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
from redminelib.resources import Issue from redminelib.resources import Issue
@@ -48,12 +49,21 @@ def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
def _create_redmine() -> Redmine: def _create_redmine() -> Redmine:
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24).""" """Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24).
При verify=True подключает системное хранилище CA ОС через
truststore.inject_into_ssl() (#62).
"""
verify = Config.get_redmine_verify()
if verify is True:
# verify_ssl: true — проверка по системному хранилищу CA ОС (truststore),
# а не по certifi: корпоративные CA из ОС продолжают работать (#62).
truststore.inject_into_ssl()
redmine = Redmine( redmine = Redmine(
Config.get_redmine_url(), Config.get_redmine_url(),
**_get_redmine_auth_kwargs(), **_get_redmine_auth_kwargs(),
requests={ requests={
"verify": Config.get_redmine_verify(), "verify": verify,
"timeout": REQUEST_TIMEOUT, "timeout": REQUEST_TIMEOUT,
}, },
) )
@@ -121,7 +131,14 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
try: try:
activities = redmine.enumeration.filter(resource="time_entry_activities") activities = redmine.enumeration.filter(resource="time_entry_activities")
return {int(a.id): str(a.name) for a in activities} return {int(a.id): str(a.name) for a in activities}
except Exception: except Exception as exc:
# #61: не глотаем сбой молча — предупреждаем, что разбивка по
# активностям будет построена по сырым данным из самих entries.
print(
f"⚠️ Could not load time entry activities: {exc}. "
"Activity names will be taken from time entries.",
file=sys.stderr,
)
return {} return {}
@@ -145,25 +162,6 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str:
return str(activity) return str(activity)
def _parse_datetime(value: Any) -> Optional[datetime]:
"""Parse a datetime from Redmine API response.
Accepts datetime objects, ISO strings (with or without timezone),
or None. Returns a timezone-aware datetime or None.
"""
if value is None:
return None
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return dt
except (ValueError, TypeError):
return None
return None
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]: def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = [] all_issues: List[Issue] = []
@@ -197,8 +195,18 @@ def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int:
# Затем ищем по логину # Затем ищем по логину
try: try:
users = redmine.user.filter(login=text) users = redmine.user.filter(login=text)
if users: # Фильтр Redmine по логину неточный (substring-поиск), поэтому
return int(users[0].id) # выбираем только точные регистрозависимые совпадения логина (#60).
exact_matches = [u for u in users if getattr(u, "login", None) == text]
if len(exact_matches) == 1:
return int(exact_matches[0].id)
if len(exact_matches) > 1:
matches = ", ".join(str(u.id) for u in exact_matches[:5])
raise RedmineAPIError(
f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID."
)
except RedmineAPIError:
raise
except Exception as exc: except Exception as exc:
raise RedmineAPIError( raise RedmineAPIError(
f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}", f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}",
@@ -242,15 +250,12 @@ def fetch_issues_with_spent_time(
to_date: str, to_date: str,
user_id: Optional[Union[int, str]] = None, user_id: Optional[Union[int, str]] = None,
by_activity: bool = False, by_activity: bool = False,
dedup_before: Optional[datetime] = None,
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]: ) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]:
""" """
Fetch unique issues linked to time entries of the given user in date range, Fetch unique issues linked to time entries of the given user in date range,
along with total spent hours per issue. along with total spent hours per issue.
If user_id is None, uses current user. If user_id is None, uses current user.
If by_activity is True, returns per-activity breakdown as third tuple element. If by_activity is True, returns per-activity breakdown as third tuple element.
If dedup_before is set, filters out time entries whose created_on AND updated_on
are both before dedup_before (AND logic: both must be < cutoff to exclude).
Returns list of (issue, total_hours, activities) tuples. Returns list of (issue, total_hours, activities) tuples.
Raises RedmineAPIError on API/auth/network failures. Raises RedmineAPIError on API/auth/network failures.
""" """
@@ -265,7 +270,9 @@ def fetch_issues_with_spent_time(
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {} activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
time_entries = list( time_entries = list(
redmine.time_entry.filter( redmine.time_entry.filter(
user_id=target_user_id, from_date=from_date, to_date=to_date user_id=target_user_id,
from_date=from_date,
to_date=to_date,
) )
) )
except RedmineAPIError: except RedmineAPIError:
@@ -273,26 +280,6 @@ def fetch_issues_with_spent_time(
except Exception as exc: except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте.
# Запись исключается, если BOTH created_on AND updated_on < dedup_before.
# Записи без метаданных (created_on/updated_on == None) не фильтруются.
if dedup_before is not None:
filtered: list = []
for entry in time_entries:
created = _parse_datetime(getattr(entry, "created_on", None))
updated = _parse_datetime(getattr(entry, "updated_on", None))
if created is None and updated is None:
filtered.append(entry)
elif created is not None and updated is not None:
if created >= dedup_before and updated >= dedup_before:
filtered.append(entry)
elif created is not None and created >= dedup_before:
filtered.append(entry)
elif updated is not None and updated >= dedup_before:
filtered.append(entry)
time_entries = filtered
# Агрегируем часы по issue.id (и активности, если требуется) # Агрегируем часы по issue.id (и активности, если требуется)
spent_time: Dict[int, float] = {} spent_time: Dict[int, float] = {}
spent_by_activity: Dict[int, Dict[str, float]] = {} spent_by_activity: Dict[int, Dict[str, float]] = {}
@@ -319,6 +306,20 @@ def fetch_issues_with_spent_time(
except Exception as exc: except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# #61: задачи могли не вернуться из issue.filter (нет прав / удалены) —
# предупреждаем о выпавших задачах и потерянных часах, но строим отчёт
# по доступным данным.
returned_ids = {issue.id for issue in issues}
missing_ids = sorted(issue_ids - returned_ids)
if missing_ids:
lost_hours = sum(spent_time[iid] for iid in missing_ids)
print(
f"⚠️ {len(missing_ids)} issue(s) unavailable (no access or deleted; "
f"IDs: {', '.join(str(i) for i in missing_ids)}): "
f"{lost_hours:g}h excluded from the report.",
file=sys.stderr,
)
# Сопоставляем задачи с суммарным временем. # Сопоставляем задачи с суммарным временем.
# Сортировка выполняется в report_builder.build_grouped_report, # Сортировка выполняется в report_builder.build_grouped_report,
# здесь оставляем порядок API как есть. # здесь оставляем порядок API как есть.

View File

@@ -1,20 +1,19 @@
import logging import logging
import os import os
import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date, timedelta from datetime import date
from pathlib import Path from pathlib import Path
from typing import Union from typing import Dict, Union
import yaml import yaml
from dotenv import load_dotenv from dotenv import load_dotenv
from .yaml_config import check_file_permissions, resolve_env_vars from .yaml_config import check_file_permissions, resolve_env_vars
load_dotenv(override=False)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt" DEFAULT_REDMINE_VERIFY: Union[bool, str] = True
FALSE_VALUES = {"0", "false", "no", "off"} FALSE_VALUES = {"0", "false", "no", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"} TRUE_VALUES = {"1", "true", "yes", "on"}
@@ -41,28 +40,18 @@ class EmailConfig:
html: bool = False html: bool = False
@dataclass
class PeriodLastUsed:
from_: str = ""
to: str = ""
@dataclass @dataclass
class AppConfig: class AppConfig:
redmine_url: str = "" redmine_url: str = ""
redmine_api_key: str = "" redmine_api_key: str = ""
redmine_author: str = "" redmine_author: str = ""
redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY
period_precision: str = "date" period_from: str = ""
period_default_from: str = ""
period_default_to: str = ""
period_dynamic: bool = False
period_last_used_from: str = ""
period_last_used_to: str = ""
output_dir: str = "" output_dir: str = ""
output_filename: str = "{author}_{from}_{to}.{ext}" output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx" output_default_format: str = "xlsx"
report_no_time: bool = False report_no_time: bool = False
report_status_translation: Dict[str, str] = field(default_factory=dict)
email: EmailConfig = field(default_factory=EmailConfig) email: EmailConfig = field(default_factory=EmailConfig)
@classmethod @classmethod
@@ -100,32 +89,19 @@ class AppConfig:
redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"), redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"),
redmine_author=cls._resolve_str(raw, "redmine", "author"), redmine_author=cls._resolve_str(raw, "redmine", "author"),
redmine_verify=cls._resolve_verify(raw), redmine_verify=cls._resolve_verify(raw),
period_precision=cls._resolve_str(raw, "period", "precision") or "date", period_from=cls._resolve_str(raw, "period", "from"),
period_default_from=cls._resolve_str(raw, "period", "default_from"),
period_default_to=cls._resolve_str(raw, "period", "default_to"),
period_dynamic=cls._resolve_bool(raw, "period", "dynamic"),
period_last_used_from=cls._resolve_str(raw, "period", "last_used", "from"),
period_last_used_to=cls._resolve_str(raw, "period", "last_used", "to"),
output_dir=cls._resolve_str(raw, "output", "dir"), output_dir=cls._resolve_str(raw, "output", "dir"),
output_filename=cls._resolve_str(raw, "output", "filename") output_filename=cls._resolve_str(raw, "output", "filename")
or "{author}_{from}_{to}.{ext}", or "{author}_{from}_{to}.{ext}",
output_default_format=cls._resolve_str(raw, "output", "default_format") output_default_format=cls._resolve_str(raw, "output", "default_format")
or "xlsx", or "xlsx",
report_no_time=cls._resolve_bool(raw, "report", "no_time"), report_no_time=cls._resolve_bool(raw, "report", "no_time"),
report_status_translation=cls._resolve_str_dict(
raw, "report", "status_translation"
),
email=cls._resolve_email(raw), email=cls._resolve_email(raw),
) )
@classmethod
def from_env(cls) -> "AppConfig":
"""Загружает настройки из переменных окружения."""
return cls(
redmine_url=os.getenv("REDMINE_URL", "").strip().rstrip("/"),
redmine_api_key=os.getenv("REDMINE_API_KEY", "").strip(),
redmine_author=os.getenv("REDMINE_AUTHOR", "").strip(),
period_default_from=os.getenv("DEFAULT_FROM_DATE", "").strip(),
period_default_to=os.getenv("DEFAULT_TO_DATE", "").strip(),
)
@staticmethod @staticmethod
def _resolve_str(raw: dict, section: str, *keys: str) -> str: def _resolve_str(raw: dict, section: str, *keys: str) -> str:
value = raw.get(section) value = raw.get(section)
@@ -141,6 +117,43 @@ class AppConfig:
return "" return ""
return str(value) return str(value)
@staticmethod
def _resolve_str_dict(raw: dict, section: str, key: str) -> Dict[str, str]:
"""Читает вложенный mapping строк из YAML-секции.
Не-mapping значение логируется с warning и игнорируется.
Ключи и значения приводятся к str, ${VAR} в значениях резолвится.
Записи со значением null или не-scalar (dict/list) пропускаются
с warning — иначе null стал бы строкой "None", а dict — repr.
"""
section_value = raw.get(section)
if not isinstance(section_value, dict):
return {}
value = section_value.get(key)
if value is None:
return {}
if not isinstance(value, dict):
logger.warning(
"Config %s.%s must be a mapping, got %s — ignoring",
section,
key,
type(value).__name__,
)
return {}
result: Dict[str, str] = {}
for k, v in value.items():
if v is None or isinstance(v, (dict, list)):
logger.warning(
"Config %s.%s entry %r must be a scalar, got %s — skipping",
section,
key,
k,
type(v).__name__,
)
continue
result[str(k)] = resolve_env_vars(str(v))
return result
@staticmethod @staticmethod
def _resolve_bool(raw: dict, section: str, key: str) -> bool: def _resolve_bool(raw: dict, section: str, key: str) -> bool:
value = raw.get(section, {}).get(key) value = raw.get(section, {}).get(key)
@@ -156,17 +169,23 @@ class AppConfig:
@classmethod @classmethod
def _resolve_verify(cls, raw: dict) -> Union[bool, str]: def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
"""Семантика verify_ssl, единая с REDMINE_VERIFY из env:
- true (bool или строка) → True — стандартная проверка TLS (requests);
- false (bool или строка) → False — проверка отключена;
- любая другая строка → путь к CA-bundle как есть.
"""
value = raw.get("redmine", {}).get("verify_ssl") value = raw.get("redmine", {}).get("verify_ssl")
if value is None: if value is None:
return DEFAULT_REDMINE_VERIFY return DEFAULT_REDMINE_VERIFY
if isinstance(value, bool): if isinstance(value, bool):
return DEFAULT_REDMINE_VERIFY if value else False return value
if isinstance(value, str): if isinstance(value, str):
normalized = value.lower() normalized = value.lower()
if normalized in FALSE_VALUES: if normalized in FALSE_VALUES:
return False return False
if normalized in TRUE_VALUES: if normalized in TRUE_VALUES:
return DEFAULT_REDMINE_VERIFY return True
return resolve_env_vars(value) return resolve_env_vars(value)
return DEFAULT_REDMINE_VERIFY return DEFAULT_REDMINE_VERIFY
@@ -242,7 +261,13 @@ class Config:
@classmethod @classmethod
def load_yaml(cls, path: str) -> None: def load_yaml(cls, path: str) -> None:
"""Загружает YAML-конфиг. Не бросает исключений при отсутствии файла.""" """Загружает конфигурацию приложения.
Сначала подгружает .env из текущей директории (override=False:
переменные окружения не перебиваются), затем читает YAML-конфиг.
Не бросает исключений при отсутствии файла.
"""
load_dotenv(override=False)
cls._app = AppConfig.from_yaml(path) cls._app = AppConfig.from_yaml(path)
@classmethod @classmethod
@@ -313,24 +338,6 @@ class Config:
return cls._app.redmine_author return cls._app.redmine_author
return "" return ""
@classmethod
def get_period_precision(cls) -> str:
if cls._app:
return cls._app.period_precision or "date"
return "date"
@classmethod
def get_last_used_from(cls) -> str:
if cls._app:
return cls._app.period_last_used_from
return ""
@classmethod
def get_last_used_to(cls) -> str:
if cls._app:
return cls._app.period_last_used_to
return ""
@classmethod @classmethod
def get_output_dir(cls) -> str: def get_output_dir(cls) -> str:
if cls._app: if cls._app:
@@ -356,6 +363,18 @@ class Config:
return cls._app.report_no_time return cls._app.report_no_time
return False return False
@classmethod
def get_status_translation(cls) -> Dict[str, str]:
"""Полный словарь перевода статусов: встроенный + переопределения из YAML.
Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется.
Импорт ленивый: config.py не должен тянуть redminelib при импорте.
"""
from .report_builder import STATUS_TRANSLATION
overrides = cls._app.report_status_translation if cls._app else {}
return {**STATUS_TRANSLATION, **overrides}
@classmethod @classmethod
def get_email_config(cls) -> "EmailConfig | None": def get_email_config(cls) -> "EmailConfig | None":
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен.""" """Возвращает EmailConfig из YAML-конфига или None, если не настроен."""
@@ -368,90 +387,56 @@ class Config:
@classmethod @classmethod
def get_default_date_range(cls) -> str: def get_default_date_range(cls) -> str:
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip() """Окно отчёта по умолчанию: from..сегодня.
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
Приоритет начала: env FROM_DATE -> YAML period.from -> 1-е число
текущего месяца. Некорректная дата не роняет CLI -- логируется
warning и берётся текущий месяц.
"""
today = date.today() today = date.today()
today_str = today.isoformat() raw_from = os.getenv("FROM_DATE", "").strip()
if not raw_from and cls._app:
raw_from = cls._app.period_from
if from_env: if raw_from:
return f"{from_env}--{to_env or today_str}" try:
start = date.fromisoformat(raw_from)
if ( except (ValueError, TypeError):
cls._app logger.warning(
and cls._app.period_dynamic "Invalid FROM_DATE/period.from %r -- expected ISO date "
and cls._app.period_last_used_from "(YYYY-MM-DD); using current month",
and cls._app.period_last_used_to raw_from,
):
nf, nt = compute_next_period(
cls._app.period_last_used_from,
cls._app.period_last_used_to,
cls._app.period_precision or "date",
) )
return f"{nf}--{nt}"
if cls._app and cls._app.period_default_from:
default_to = cls._app.period_default_to or today_str
return f"{cls._app.period_default_from}--{default_to}"
start = today.replace(day=1) start = today.replace(day=1)
return f"{start.isoformat()}--{today_str}" else:
start = today.replace(day=1)
# Будущая start инвертирует диапазон и ломает фильтр Redmine,
# поэтому сжимаем окно до сегодняшнего дня.
if start > today:
start = today
return f"{start.isoformat()}--{today.isoformat()}"
@classmethod @classmethod
def validate(cls) -> None: def validate(cls) -> None:
if not cls.get_redmine_url(): url = cls.get_redmine_url()
if not url:
raise ValueError("REDMINE_URL is required (set via env or .env)") raise ValueError("REDMINE_URL is required (set via env or .env)")
if not url.lower().startswith("https://"):
raise ValueError(
"REDMINE_URL must use HTTPS: the API key is sent in request "
"headers and requires TLS"
)
if cls.get_redmine_verify() is False:
print(
"⚠️ TLS certificate verification is disabled "
"(REDMINE_VERIFY=false / verify_ssl: false): connection is "
"vulnerable to MITM attacks",
file=sys.stderr,
)
if cls.get_redmine_api_key(): if cls.get_redmine_api_key():
return return
if not (cls.get_redmine_user() and cls.get_redmine_password()): if not (cls.get_redmine_user() and cls.get_redmine_password()):
raise ValueError( raise ValueError(
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD" "REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
) )
def compute_next_period(
last_from: str, last_to: str, precision: str
) -> tuple[str, str]:
"""Compute the next report period based on the last committed period.
- For a full calendar month → next full calendar month.
- For an arbitrary range → same duration, starting the day after last_to.
- For datetime precision → same duration, starting 1 second after last_to.
"""
from datetime import datetime as dt_mod
if precision == "datetime":
fmt = "%Y-%m-%dT%H:%M:%S"
from_dt = dt_mod.fromisoformat(last_from.replace("Z", "+00:00"))
to_dt = dt_mod.fromisoformat(last_to.replace("Z", "+00:00"))
duration = to_dt - from_dt
next_from_dt = to_dt + timedelta(seconds=1)
next_to_dt = next_from_dt + duration
return next_from_dt.strftime(fmt), next_to_dt.strftime(fmt)
from_parts = last_from.split("-")
to_parts = last_to.split("-")
from_d = date(int(from_parts[0]), int(from_parts[1]), int(from_parts[2]))
to_d = date(int(to_parts[0]), int(to_parts[1]), int(to_parts[2]))
first_of_month = from_d.replace(day=1)
if from_d == first_of_month:
if to_d.month == 12:
last_of_month = date(to_d.year, 12, 31)
else:
next_first = date(to_d.year, to_d.month + 1, 1)
last_of_month = next_first - timedelta(days=1)
if to_d == last_of_month:
if to_d.month == 12:
nstart = date(to_d.year + 1, 1, 1)
else:
nstart = date(to_d.year, to_d.month + 1, 1)
if nstart.month == 12:
nend = date(nstart.year, 12, 31)
else:
nend = date(nstart.year, nstart.month + 1, 1) - timedelta(days=1)
return nstart.isoformat(), nend.isoformat()
duration = to_d - from_d
next_from = to_d + timedelta(days=1)
next_to = next_from + duration
return next_from.isoformat(), next_to.isoformat()

View File

@@ -39,9 +39,6 @@ class XLSXFormatter(Formatter):
def format(self, rows: List[ReportRow]) -> Workbook: def format(self, rows: List[ReportRow]) -> Workbook:
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
if ws is None:
ws = wb.create_sheet("Report")
else:
ws.title = "Report" ws.title = "Report"
headers = [ headers = [

View File

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

View File

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

View File

@@ -153,19 +153,11 @@ def resolve_output_path(
return arg return arg
def save_period_to_config( def save_period_to_config(config_path: str, from_str: str) -> None:
config_path: str, """Save committed period start to YAML config file.
from_str: str,
to_str: str,
precision: str,
dynamic: bool,
) -> None:
"""Save committed period to YAML config file.
Writes period.last_used.from / period.last_used.to. Writes period.from only: all other sections and keys stay untouched.
When dynamic=False, also overwrites period.default_from / period.default_to. Creates the config file (and its parent directory) if missing.
Preserves all other sections unchanged. Creates config file if missing.
""" """
import yaml import yaml
@@ -176,11 +168,7 @@ def save_period_to_config(
raw = yaml.safe_load(fh) or {} raw = yaml.safe_load(fh) or {}
period = raw.setdefault("period", {}) period = raw.setdefault("period", {})
period["last_used"] = {"from": from_str, "to": to_str} period["from"] = from_str
if not dynamic:
period["default_from"] = from_str.split("T")[0] if "T" in from_str else from_str
period["default_to"] = to_str.split("T")[0] if "T" in to_str else to_str
ensure_config_dir(path.parent) ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh: with open(path, "w", encoding="utf-8") as fh:

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,18 @@ def _configure_current_user(mock_redmine, user_id=1):
mock_redmine.user.get.return_value = mock_user mock_redmine.user.get.return_value = mock_user
@pytest.fixture(autouse=True)
def mock_truststore():
"""Мок truststore для всех тестов клиента.
При verify=True _create_redmine() вызывает truststore.inject_into_ssl(),
который глобально подменяет ssl.SSLContext без восстановления (#62).
Не даём реальной инъекции выполниться в pytest-процессе.
"""
with mock.patch("redmine_reporter.client.truststore") as m:
yield m
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_fetch_aggregates_hours_per_issue(mock_redmine_class): def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
@@ -172,6 +184,70 @@ def test_fetch_uses_custom_verify_path(mock_redmine_class):
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem" assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "true"}, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_true_injects_system_trust_store(mock_redmine_class, mock_truststore):
"""verify=True → truststore.inject_into_ssl() вызван, verify=True в Redmine (#62)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_called_once_with()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is True
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "false"}, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_false_skips_truststore_injection(mock_redmine_class, mock_truststore):
"""verify=False → truststore.inject_into_ssl() не вызывается."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_not_called()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is False
@mock.patch.dict(
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_custom_path_skips_truststore_injection(
mock_redmine_class, mock_truststore
):
"""verify=<путь к CA-bundle> → truststore.inject_into_ssl() не вызывается."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_not_called()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_verify_default_injects_system_trust_store(mock_redmine_class, mock_truststore):
"""Без REDMINE_VERIFY verify по умолчанию True → inject вызван (#62)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
mock_truststore.inject_into_ssl.assert_called_once_with()
_, kwargs = mock_redmine_class.call_args
assert kwargs["requests"]["verify"] is True
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class): def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
@@ -275,6 +351,7 @@ def test_fetch_resolves_user_login_to_id(mock_redmine_class):
mock_user = mock.MagicMock() mock_user = mock.MagicMock()
mock_user.id = 7 mock_user.id = 7
mock_user.login = "ivanov"
mock_redmine.user.filter.return_value = [mock_user] mock_redmine.user.filter.return_value = [mock_user]
mock_redmine.time_entry.filter.return_value = [] mock_redmine.time_entry.filter.return_value = []
@@ -287,6 +364,91 @@ def test_fetch_resolves_user_login_to_id(mock_redmine_class):
assert kwargs["user_id"] == 7 assert kwargs["user_id"] == 7
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_inexact_login_match(mock_redmine_class):
"""Фильтр Redmine по логину неточный (#60): без точного совпадения — ошибка."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
near_match = mock.MagicMock()
near_match.id = 8
near_match.login = "ivanov2"
mock_redmine.user.filter.side_effect = [
[near_match], # неточное совпадение по логину
[], # по имени никого не найдено
]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="not found"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_resolves_exact_login_among_near_matches(mock_redmine_class):
"""Среди неточных результатов фильтра выбирается точное совпадение логина (#60)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
near = mock.MagicMock()
near.id = 8
near.login = "ivanov2"
exact = mock.MagicMock()
exact.id = 7
exact.login = "ivanov"
mock_redmine.user.filter.return_value = [near, exact]
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 7
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_multiple_exact_login_matches(mock_redmine_class):
"""Несколько пользователей с точным логином — ошибка неоднозначности (#60)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
user1 = mock.MagicMock()
user1.id = 7
user1.login = "ivanov"
user2 = mock.MagicMock()
user2.id = 9
user2.login = "ivanov"
mock_redmine.user.filter.return_value = [user1, user2]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Multiple users match"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_login_exact_match_is_case_sensitive(mock_redmine_class):
"""Точное совпадение логина регистрозависимое (#60)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
other_case = mock.MagicMock()
other_case.id = 8
other_case.login = "Ivanov"
mock_redmine.user.filter.side_effect = [
[other_case], # логин отличается регистром
[], # по имени никого не найдено
]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="not found"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_fetch_resolves_user_name_to_id(mock_redmine_class): def test_fetch_resolves_user_name_to_id(mock_redmine_class):
@@ -443,33 +605,36 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
assert len(result) == 250 assert len(result) == 250
# -- #47: Дедупликация time entries по datetime -- # -- Пагинация time entries (>100 записей) --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class): def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
"""Записи с created_on/updated_on раньше dedup_before исключаются.""" """Time entries приходят страницами по 100 — часы агрегируются по всем страницам."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123) _configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) def make_entry(issue_id, hours):
e = mock.MagicMock()
e.issue.id = issue_id
e.hours = hours
return e
e1 = mock.MagicMock() # Эмулируем ленивую пагинацию redminelib ResourceSet: записи отдаются
e1.issue.id = 1 # генератором порциями ("страницами" по 100), как при догрузке с сервера.
e1.hours = 2.0 # Если клиент прочитает только первую страницу, агрегация будет неверной.
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc) pages = [
e1.updated_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc) [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)],
]
e2 = mock.MagicMock() def paged_entries():
e2.issue.id = 2 for page in pages:
e2.hours = 1.0 yield from page
e2.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e2.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
mock_redmine.time_entry.filter.return_value = [e1, e2] mock_redmine.time_entry.filter.return_value = paged_entries()
mock_issue1 = mock.MagicMock() mock_issue1 = mock.MagicMock()
mock_issue1.id = 1 mock_issue1.id = 1
@@ -479,215 +644,164 @@ def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
mock_issue2.project = "P" mock_issue2.project = "P"
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2] mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time( result = fetch_issues_with_spent_time("2026-01-01", "2026-03-31")
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None assert result is not None
assert len(result) == 1 hours_by_id = {issue.id: hours for issue, hours, _ in result}
assert result[0][0].id == 2 # issue 1: 100×0.5 (стр. 1) + 50×0.5 (стр. 3) = 75.0
assert hours_by_id[1] == 75.0
# issue 2: 100×1.0 (стр. 2) = 100.0
assert hours_by_id[2] == 100.0
# -- #61: предупреждения о молчаливой потере данных --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_dedup_filters_entries_with_old_created_even_if_updated_recently( def test_fetch_warns_about_missing_issues(mock_redmine_class, capsys):
mock_redmine_class, """Задачи, не вернувшиеся из issue.filter, попадают в warning в stderr (#61).
):
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
from datetime import datetime, timezone
Отчёт при этом строится по доступным данным — это предупреждение, не ошибка.
"""
mock_redmine = mock_redmine_class.return_value mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123) _configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) def make_entry(issue_id, hours):
e = mock.MagicMock()
e.issue.id = issue_id
e.hours = hours
return e
e1 = mock.MagicMock() mock_redmine.time_entry.filter.return_value = [
e1.issue.id = 1 make_entry(1, 1.0),
e1.hours = 2.0 make_entry(2, 2.5),
e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc) make_entry(3, 0.75),
e1.updated_on = datetime(2026, 7, 1, 15, 0, 0, tzinfo=timezone.utc) ]
mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
"""Записи с created_on/updated_on >= dedup_before сохраняются."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e1.updated_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock() mock_issue1 = mock.MagicMock()
mock_issue1.id = 1 mock_issue1.id = 1
mock_issue1.project = "P" mock_issue1.project = "P"
mock_issue1.subject = "T" mock_issue1.subject = "T"
mock_issue1.status = "New" mock_issue1.status = "New"
# Задачи 2 и 3 не вернулись (нет прав / удалены)
mock_redmine.issue.filter.return_value = [mock_issue1] mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time( result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None assert result is not None
assert len(result) == 1 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.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_dedup_entries_without_created_on_are_kept(mock_redmine_class): def test_fetch_no_warning_when_all_issues_returned(mock_redmine_class, capsys):
"""Записи без created_on/updated_on не фильтруются (сохраняются).""" """Если все задачи вернулись, stderr остаётся чистым (#61)."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123) _configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) e = mock.MagicMock()
e.issue.id = 1
e1 = mock.MagicMock() e.hours = 1.0
e1.issue.id = 1 mock_redmine.time_entry.filter.return_value = [e]
e1.hours = 2.0
e1.created_on = None
e1.updated_on = None
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock() mock_issue1 = mock.MagicMock()
mock_issue1.id = 1 mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1] mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time( result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None assert result is not None
assert len(result) == 1 captured = capsys.readouterr()
assert captured.err == ""
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_dedup_handles_string_created_on(mock_redmine_class): def test_fetch_warns_when_activities_lookup_fails(mock_redmine_class, capsys):
"""created_on может быть строкой ISO — парсим корректно.""" """Сбой справочника активностей — warning в stderr, отчёт строится (#61)."""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123) _configure_current_user(mock_redmine, user_id=123)
mock_redmine.enumeration.filter.side_effect = Exception("enumeration boom")
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) e = mock.MagicMock()
e.issue.id = 1
e1 = mock.MagicMock() e.hours = 1.0
e1.issue.id = 1 e.activity.id = 9
e1.hours = 2.0 e.activity.name = "Dev"
e1.created_on = "2026-07-01T10:00:00Z" mock_redmine.time_entry.filter.return_value = [e]
e1.updated_on = "2026-07-01T10:00:00Z"
mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_no_dedup_when_cutoff_is_none(mock_redmine_class):
"""Без dedup_before фильтрация не применяется."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
e1 = mock.MagicMock()
e1.issue.id = 1
e1.hours = 2.0
e1.created_on = None
e1.updated_on = None
mock_redmine.time_entry.filter.return_value = [e1]
mock_issue1 = mock.MagicMock() mock_issue1 = mock.MagicMock()
mock_issue1.id = 1 mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1] mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=None) result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31", by_activity=True)
assert result is not None assert result is not None
assert len(result) == 1 assert result[0][2] == {"Dev": 1.0} # fallback на имя из самого entry
captured = capsys.readouterr()
assert "activit" in captured.err.lower()
def test_load_time_entry_activities_returns_empty_on_failure(capsys):
"""_load_time_entry_activities возвращает {} и пишет warning при сбое (#61)."""
from redmine_reporter.client import _load_time_entry_activities
redmine = mock.MagicMock()
redmine.enumeration.filter.side_effect = Exception("enumeration boom")
assert _load_time_entry_activities(redmine) == {}
captured = capsys.readouterr()
assert "activit" in captured.err.lower()
# -- Границы периода передаются в API как есть --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_dedup_mixed_entries_correct_filtering(mock_redmine_class): def test_fetch_passes_date_boundaries_to_api_unchanged(mock_redmine_class):
"""Смешанные записи: старые исключаются, новые сохраняются, аки идут.""" """Флоринг границ выполняет cli-парсер, клиент отдаёт даты в filter как есть."""
from datetime import datetime, timezone mock_redmine = mock_redmine_class.return_value
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-09-30", "2026-10-31", user_id=458)
mock_redmine.time_entry.filter.assert_called_once_with(
user_id=458, from_date="2026-09-30", to_date="2026-10-31"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_works_without_dedup_argument(mock_redmine_class):
"""dedup-слой удалён: без dedup-аргумента все записи периода агрегируются."""
mock_redmine = mock_redmine_class.return_value mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123) _configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) entry = mock.MagicMock()
entry.issue.id = 1
entry.hours = 2.0
entry.created_on = None
entry.updated_on = None
mock_redmine.time_entry.filter.return_value = [entry]
e_old = mock.MagicMock() mock_issue = mock.MagicMock()
e_old.issue.id = 10 mock_issue.id = 1
e_old.hours = 1.0 mock_issue.project = "P"
e_old.created_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc) mock_issue.subject = "T"
e_old.updated_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc) mock_issue.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue]
e_new = mock.MagicMock() result = fetch_issues_with_spent_time("2026-09-30", "2026-09-30", user_id=123)
e_new.issue.id = 20
e_new.hours = 3.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc)
e_akiy = mock.MagicMock()
e_akiy.issue.id = 30
e_akiy.hours = 5.0
e_akiy.created_on = None
e_akiy.updated_on = None
mock_redmine.time_entry.filter.return_value = [e_old, e_new, e_akiy]
mock_issue_new = mock.MagicMock()
mock_issue_new.id = 20
mock_issue_new.project = "P"
mock_issue_new.subject = "T"
mock_issue_new.status = "New"
mock_issue_akiy = mock.MagicMock()
mock_issue_akiy.id = 30
mock_issue_akiy.project = "P"
mock_issue_akiy.subject = "T2"
mock_issue_akiy.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None assert result is not None
assert len(result) == 2 assert [r[0].id for r in result] == [1]
ids = {r[0].id for r in result} assert result[0][1] == 2.0
assert ids == {20, 30}

View File

@@ -1,3 +1,4 @@
import contextlib
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -5,12 +6,19 @@ from unittest import mock
import pytest import pytest
from redmine_reporter.config import ( from redmine_reporter.config import AppConfig, Config, EmailConfig
DEFAULT_REDMINE_VERIFY,
AppConfig,
Config, @contextlib.contextmanager
EmailConfig, def mock_today(fake_today):
) """Подменяет date.today() в config, сохраняя реальные date() и fromisoformat()."""
from datetime import date as real_date
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = fake_today
mock_date.side_effect = lambda *a, **kw: real_date(*a, **kw)
mock_date.fromisoformat.side_effect = real_date.fromisoformat
yield
@mock.patch.dict( @mock.patch.dict(
@@ -67,25 +75,9 @@ def test_get_author_fallback():
assert Config.get_author("") == "" assert Config.get_author("") == ""
@mock.patch.dict( @mock.patch.dict(os.environ, {"FROM_DATE": "2026-01-01"}, clear=True)
os.environ,
{
"DEFAULT_FROM_DATE": "2026-01-01",
"DEFAULT_TO_DATE": "2026-01-31",
},
clear=True,
)
def test_get_default_date_range_from_env(): def test_get_default_date_range_from_env():
assert Config.get_default_date_range() == "2026-01-01--2026-01-31" """FROM_DATE задаёт начало периода, конец всегда -- сегодня."""
@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 from datetime import date
today = date.today().isoformat() today = date.today().isoformat()
@@ -104,9 +96,131 @@ def test_get_default_date_range_fallback():
assert result == f"{start.isoformat()}--{today.isoformat()}" assert result == f"{start.isoformat()}--{today.isoformat()}"
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) --
class TestDefaultPeriodIsCurrentMonth:
"""Без --date, env и YAML период = с 1-го числа текущего месяца по сегодня."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_mid_month(self):
from datetime import date
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 3, 14)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
Config._app = None
assert Config.get_default_date_range() == "2026-03-01--2026-03-14"
@mock.patch.dict(os.environ, {}, clear=True)
def test_first_day_of_month(self):
from datetime import date
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 5, 1)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
Config._app = None
assert Config.get_default_date_range() == "2026-05-01--2026-05-01"
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_period_from_runs_to_today(self):
"""period.from задан -- конец периода всегда сегодня."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n from: '2026-04-01'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with mock_today(date(2026, 4, 20)):
assert Config.get_default_date_range() == "2026-04-01--2026-04-20"
class TestDefaultDateRangePeriodFrom:
"""Приоритет окна: env FROM_DATE → YAML period.from → текущий месяц."""
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-02-01"}, clear=True)
def test_env_from_runs_to_today(self):
"""FROM_DATE задаёт начало, конец всегда сегодня."""
from datetime import date
Config._app = None
with mock_today(date(2026, 2, 10)):
assert Config.get_default_date_range() == "2026-02-01--2026-02-10"
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True)
def test_env_from_wins_over_yaml_period_from(self):
"""env FROM_DATE имеет приоритет над YAML period.from."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n from: '2026-03-01'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with mock_today(date(2026, 7, 15)):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
@mock.patch.dict(os.environ, {"FROM_DATE": "2026-12-31"}, clear=True)
def test_env_from_in_future_clamps_to_today(self):
"""from позже сегодня -- окно 'сегодня--сегодня', без инверсии."""
from datetime import date
Config._app = None
with mock_today(date(2026, 9, 15)):
assert Config.get_default_date_range() == "2026-09-15--2026-09-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_period_from_in_future_clamps_to_today(self):
"""YAML period.from позже сегодня -- окно 'сегодня--сегодня'."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n from: '2026-12-31'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with mock_today(date(2026, 9, 15)):
assert Config.get_default_date_range() == "2026-09-15--2026-09-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_malformed_yaml_period_from_warns_and_uses_current_month(self, caplog):
"""period.from не дата -- warning в лог, окно = текущий месяц."""
import logging
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n from: 'not-a-date'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with caplog.at_level(logging.WARNING):
with mock_today(date(2026, 9, 15)):
rng = Config.get_default_date_range()
assert rng == "2026-09-01--2026-09-15"
assert "not-a-date" in caplog.text
@mock.patch.dict(os.environ, {"FROM_DATE": "garbage"}, clear=True)
def test_malformed_env_from_warns_and_uses_current_month(self, caplog):
"""FROM_DATE не дата -- warning в лог, окно = текущий месяц."""
import logging
from datetime import date
Config._app = None
with caplog.at_level(logging.WARNING):
with mock_today(date(2026, 9, 15)):
rng = Config.get_default_date_range()
assert rng == "2026-09-01--2026-09-15"
assert "garbage" in caplog.text
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_get_redmine_verify_default(): def test_get_redmine_verify_default():
assert Config.get_redmine_verify() == DEFAULT_REDMINE_VERIFY """Без явной настройки проверка TLS включена (стандартные CA requests)."""
Config._app = None
assert Config.get_redmine_verify() is True
@pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"]) @pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"])
@@ -126,17 +240,103 @@ def test_get_redmine_verify_custom_path():
assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem" assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem"
# -- #54: REDMINE_URL обязан использовать HTTPS --
class TestConfigRequiresHttpsUrl:
"""Config.validate() отклоняет URL без TLS: API-ключ идёт в заголовках."""
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "http://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_http_url_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "red.eltex.loc", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_url_without_scheme_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "ftp://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_non_http_scheme_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_https_url_accepted(self):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "HTTPS://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_uppercase_scheme_accepted(self):
Config.validate()
# -- #57: предупреждение при отключённой проверке TLS --
class TestWarnWhenTlsVerificationDisabled:
"""verify_ssl: false должен давать видимый warning в stderr (риск MITM)."""
_ENV = {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"}
def _validate_env(self, verify: str):
return {**self._ENV, "REDMINE_VERIFY": verify}
def test_verify_false_warns_in_stderr(self, capsys):
with mock.patch.dict(os.environ, self._validate_env("false"), clear=True):
Config.validate()
err = capsys.readouterr().err
assert "⚠️" in err
assert "TLS" in err or "SSL" in err
def test_verify_true_no_warning(self, capsys):
with mock.patch.dict(os.environ, self._validate_env("true"), clear=True):
Config.validate()
assert capsys.readouterr().err == ""
def test_verify_ca_path_no_warning(self, capsys):
with mock.patch.dict(
os.environ, self._validate_env("/tmp/redmine-ca.pem"), clear=True
):
Config.validate()
assert capsys.readouterr().err == ""
def test_verify_default_no_warning(self, capsys):
with mock.patch.dict(os.environ, self._ENV, clear=True):
Config.validate()
assert capsys.readouterr().err == ""
# -- #15: .env не должен переопределять переменные окружения -- # -- #15: .env не должен переопределять переменные окружения --
@mock.patch("dotenv.load_dotenv") @mock.patch("redmine_reporter.config.load_dotenv")
def test_env_var_takes_priority_over_dotenv(mock_load): def test_env_var_takes_priority_over_dotenv(mock_load):
"""load_dotenv вызывается с override=False — env vars не перебиваются .env.""" """load_dotenv вызывается с override=False — env vars не перебиваются .env."""
import importlib Config.load_yaml("/nonexistent/config.yml")
from redmine_reporter import config as cfg_mod
importlib.reload(cfg_mod)
mock_load.assert_called_once_with(override=False) mock_load.assert_called_once_with(override=False)
@@ -163,13 +363,7 @@ redmine:
verify_ssl: false verify_ssl: false
period: period:
precision: datetime from: "2026-06-01"
default_from: "2026-06-01"
default_to: "2026-06-30"
dynamic: true
last_used:
from: "2026-06-30T09:00:00"
to: "2026-06-30T12:00:00"
output: output:
dir: ~/reports dir: ~/reports
@@ -208,12 +402,7 @@ class TestAppConfigFromYaml:
assert cfg.redmine_api_key == "" # ${MISSING_API_KEY_FOR_TEST} not set assert cfg.redmine_api_key == "" # ${MISSING_API_KEY_FOR_TEST} not set
assert cfg.redmine_author == "Тестовый Автор" assert cfg.redmine_author == "Тестовый Автор"
assert cfg.redmine_verify is False assert cfg.redmine_verify is False
assert cfg.period_precision == "datetime" assert cfg.period_from == "2026-06-01"
assert cfg.period_default_from == "2026-06-01"
assert cfg.period_default_to == "2026-06-30"
assert cfg.period_dynamic is True
assert cfg.period_last_used_from == "2026-06-30T09:00:00"
assert cfg.period_last_used_to == "2026-06-30T12:00:00"
assert cfg.output_dir == "~/reports" assert cfg.output_dir == "~/reports"
assert cfg.output_filename == "{author}_{from}_{to}.{ext}" assert cfg.output_filename == "{author}_{from}_{to}.{ext}"
assert cfg.output_default_format == "xlsx" assert cfg.output_default_format == "xlsx"
@@ -236,7 +425,7 @@ class TestAppConfigFromYaml:
assert cfg.redmine_url == "https://x.com/" assert cfg.redmine_url == "https://x.com/"
# defaults for everything else # defaults for everything else
assert cfg.redmine_author == "" assert cfg.redmine_author == ""
assert cfg.period_precision == "date" assert cfg.period_from == ""
assert cfg.output_dir == "" assert cfg.output_dir == ""
assert cfg.email.to == [] assert cfg.email.to == []
@@ -249,7 +438,36 @@ class TestAppConfigFromYaml:
assert cfg.redmine_url == "" assert cfg.redmine_url == ""
assert cfg.redmine_api_key == "" assert cfg.redmine_api_key == ""
assert cfg.period_precision == "date" assert cfg.period_from == ""
def test_period_from_loaded_from_yaml(self):
"""period.from читается из YAML как единственный ключ периода."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n from: '2026-06-01'\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_from == "2026-06-01"
def test_old_period_keys_are_ignored(self):
"""Устаревшие ключи period молча игнорируются -- читается только from."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: datetime\n"
" dynamic: true\n"
" default_from: '2026-01-01'\n"
" default_to: '2026-01-31'\n"
" last_used:\n"
" from: '2026-01-31T09:00:00'\n"
" to: '2026-01-31T12:00:00'\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_from == ""
def test_file_not_found_returns_all_defaults(self): def test_file_not_found_returns_all_defaults(self):
cfg = AppConfig.from_yaml("/nonexistent/path/config.yml") cfg = AppConfig.from_yaml("/nonexistent/path/config.yml")
@@ -265,16 +483,57 @@ class TestAppConfigFromYaml:
assert cfg.redmine_api_key == "secret-token" assert cfg.redmine_api_key == "secret-token"
def test_verify_ssl_true_returns_default_ca_path(self): def test_verify_ssl_true_returns_true(self):
"""verify_ssl: true → DEFAULT_REDMINE_VERIFY (путь), не Python True.""" """verify_ssl: true → bool True (стандартная проверка TLS)."""
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml" yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n verify_ssl: true\n") yaml_path.write_text("redmine:\n verify_ssl: true\n")
cfg = AppConfig.from_yaml(yaml_path) cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify == DEFAULT_REDMINE_VERIFY assert cfg.redmine_verify is True
assert cfg.redmine_verify is not True # не бул!
@pytest.mark.parametrize("value", ["true", "True", "yes", "on", "1"])
def test_verify_ssl_true_string_returns_true(self, value):
"""Строковые true-значения в YAML → bool True, как и из env."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify is True
def test_verify_ssl_custom_path_returns_path(self):
"""Явный путь к CA-bundle в YAML передаётся как есть."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n verify_ssl: /etc/ssl/my-ca.pem\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify == "/etc/ssl/my-ca.pem"
@pytest.mark.parametrize("value", ["false", "False", "no", "off", "0"])
def test_verify_ssl_false_string_returns_false(self, value):
"""Строковые false-значения в YAML → bool False."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify is False
def test_verify_ssl_missing_returns_true(self):
"""Без verify_ssl в YAML — проверка TLS включена (True)."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n url: https://x.com/\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify is True
def test_verify_ssl_false_returns_false(self): def test_verify_ssl_false_returns_false(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -356,14 +615,15 @@ class TestConfigYamlFallback:
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_date_range_from_yaml(self): def test_date_range_from_yaml(self):
from datetime import date
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml" yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text( yaml_path.write_text("period:\n from: '2026-03-01'\n")
"period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n"
)
Config._app = AppConfig.from_yaml(yaml_path) Config._app = AppConfig.from_yaml(yaml_path)
with mock_today(date(2026, 3, 15)):
assert Config.get_default_date_range() == "2026-03-01--2026-03-15" assert Config.get_default_date_range() == "2026-03-01--2026-03-15"
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
@@ -393,205 +653,17 @@ class TestConfigYamlFallback:
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_default_date_range_env_beats_yaml(self): def test_default_date_range_env_beats_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
with mock.patch.dict(
os.environ,
{"DEFAULT_FROM_DATE": "2026-07-01", "DEFAULT_TO_DATE": "2026-07-15"},
clear=True,
):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
class TestConfigPeriodPrecision:
"""Tests for period.precision and period.last_used."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_period_precision_defaults_to_date(self):
"""Без YAML-конфига precision == 'date'."""
Config._app = None
assert Config.get_period_precision() == "date"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_period_precision_from_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n precision: datetime\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_period_precision() == "datetime"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_last_used_from_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n last_used:\n from: '2026-06-30T09:00:00'\n to: '2026-06-30T12:00:00'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_last_used_from() == "2026-06-30T09:00:00"
assert Config.get_last_used_to() == "2026-06-30T12:00:00"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_last_used_returns_empty_when_not_set(self):
Config._app = AppConfig()
assert Config.get_last_used_from() == ""
assert Config.get_last_used_to() == ""
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_loads_last_used_field(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n last_used:\n from: '2026-07-01T08:00:00'\n to: '2026-07-01T18:00:00'\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_last_used_from == "2026-07-01T08:00:00"
assert cfg.period_last_used_to == "2026-07-01T18:00:00"
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_without_last_used_has_empty_fields(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n precision: date\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_last_used_from == ""
assert cfg.period_last_used_to == ""
class TestComputeNextPeriod:
"""Tests for compute_next_period()."""
def test_full_month_goes_to_next_full_month(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-01", "2026-06-30", "date")
assert nf == "2026-07-01"
assert nt == "2026-07-31"
def test_december_to_next_year_january(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2025-12-01", "2025-12-31", "date")
assert nf == "2026-01-01"
assert nt == "2026-01-31"
def test_february_2026_non_leap_to_march(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-02-01", "2026-02-28", "date")
assert nf == "2026-03-01"
assert nt == "2026-03-31"
def test_arbitrary_range_same_length_from_to_plus_one(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-15", "2026-06-20", "date")
assert nf == "2026-06-21"
assert nt == "2026-06-26"
def test_single_day_range_moves_one_day(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-15", "2026-06-15", "date")
assert nf == "2026-06-16"
assert nt == "2026-06-16"
def test_cross_month_range(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-25", "2026-07-05", "date")
assert nf == "2026-07-06"
assert nt == "2026-07-16"
def test_datetime_precision_moves_by_seconds(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period(
"2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime"
)
assert nf == "2026-06-30T12:00:01"
assert nt == "2026-06-30T15:00:01"
class TestDefaultDateRangeWithLastUsed:
"""Tests that get_default_date_range() uses last_used when dynamic=True."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_uses_last_used_when_dynamic_true(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: true\n"
" last_used:\n"
" from: '2026-05-01'\n"
" to: '2026-05-31'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-06-01--2026-06-30"
@mock.patch.dict(os.environ, {}, clear=True)
def test_ignores_last_used_when_dynamic_false(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-03-01'\n"
" default_to: '2026-03-15'\n"
" last_used:\n"
" from: '2026-06-01'\n"
" to: '2026-06-30'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-03-01--2026-03-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_falls_back_when_no_last_used_even_with_dynamic(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: true\n"
" default_from: '2026-04-01'\n"
" default_to: '2026-04-15'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-04-01--2026-04-15"
@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 from datetime import date
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml" yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text( yaml_path.write_text("period:\n from: '2026-03-01'\n")
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-04-01'\n"
)
Config._app = AppConfig.from_yaml(yaml_path) Config._app = AppConfig.from_yaml(yaml_path)
today = date.today().isoformat()
result = Config.get_default_date_range() with mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True):
assert result == f"2026-04-01--{today}" with mock_today(date(2026, 7, 15)):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
class TestGetEmailConfig: class TestGetEmailConfig:
@@ -730,3 +802,169 @@ class TestReportNoTime:
Config._app = AppConfig.from_yaml(yaml_path) Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_report_no_time() is False assert Config.get_report_no_time() is False
# ---------------------------------------------------------------------------
# #65: report.status_translation — перевод статусов из YAML
# ---------------------------------------------------------------------------
class TestStatusTranslationFromYaml:
"""Tests for report.status_translation loading in AppConfig.from_yaml()."""
def test_status_translation_loaded_from_yaml(self):
"""report.status_translation заполняет поле AppConfig (override + новый)."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n"
" status_translation:\n"
' New: "Новая"\n'
' "Custom Status": "Кастом"\n'
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {
"New": "Новая",
"Custom Status": "Кастом",
}
def test_status_translation_missing_defaults_to_empty(self):
"""Без секции report.status_translation поле пустое."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n no_time: true\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {}
def test_status_translation_non_mapping_warns_and_ignored(self, caplog):
"""status_translation не-mapping (строка) → warning в лог, поле {}."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n status_translation: 'just a string'\n")
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {}
assert "status_translation" in caplog.text
@mock.patch.dict(os.environ, {"STATUS_RU": "На проверке"}, clear=True)
def test_status_translation_env_var_resolved(self):
"""${VAR} в значении перевода резолвится из окружения."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n status_translation:\n Testing: ${STATUS_RU}\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Testing": "На проверке"}
def test_status_translation_null_value_skipped_with_warning(self, caplog):
"""Значение null пропускается с warning, а не превращается в 'None'."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n status_translation:\n New:\n Closed: Закрыто\n"
)
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Closed": "Закрыто"}
assert "status_translation" in caplog.text
assert "New" in caplog.text
def test_status_translation_nested_dict_skipped_with_warning(self, caplog):
"""Вложенный dict в значении пропускается с warning, а не в repr."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n"
" status_translation:\n"
" New:\n"
" nested: value\n"
" Closed: Закрыто\n"
)
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Closed": "Закрыто"}
assert "status_translation" in caplog.text
assert "New" in caplog.text
def test_status_translation_int_value_coerced_to_str(self):
"""Scalar-значения (int) по-прежнему приводятся к str."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n status_translation:\n New: 42\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"New": "42"}
class TestGetStatusTranslation:
"""Tests for Config.get_status_translation()."""
def test_no_yaml_returns_builtin(self):
"""Без YAML-конфига возвращается встроенный словарь."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = None
try:
assert Config.get_status_translation() == STATUS_TRANSLATION
finally:
Config._app = None
def test_empty_overrides_returns_builtin(self):
"""Пустые overrides — результат равен встроенному словарю."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = AppConfig()
try:
assert Config.get_status_translation() == STATUS_TRANSLATION
finally:
Config._app = None
def test_override_and_extend(self):
"""Override существующего статуса + добавление нового."""
Config._app = AppConfig(
report_status_translation={"New": "Новая", "Custom Status": "Кастом"}
)
try:
translation = Config.get_status_translation()
finally:
Config._app = None
assert translation["New"] == "Новая"
assert translation["Custom Status"] == "Кастом"
# остальные встроенные переводы не тронуты
assert translation["Closed"] == "Закрыто"
def test_result_is_a_copy(self):
"""Мутация результата не трогает модульный STATUS_TRANSLATION."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = AppConfig(report_status_translation={"New": "Новая"})
try:
translation = Config.get_status_translation()
translation["New"] = "ИЗМЕНЕНО"
translation["Junk"] = "мусор"
finally:
Config._app = None
assert STATUS_TRANSLATION["New"] == "В работе"
assert "Junk" not in STATUS_TRANSLATION

View File

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

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

View File

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

View File

@@ -4,11 +4,14 @@ import tempfile
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
import yaml
from redmine_reporter.yaml_config import ( from redmine_reporter.yaml_config import (
check_file_permissions, check_file_permissions,
ensure_config_dir, ensure_config_dir,
expand_filename_template, expand_filename_template,
resolve_env_vars, resolve_env_vars,
save_period_to_config,
) )
@@ -211,120 +214,65 @@ class TestResolveOutputPath:
class TestSavePeriodToConfig: class TestSavePeriodToConfig:
"""Tests for save_period_to_config().""" """Tests for save_period_to_config()."""
def test_creates_last_used_in_empty_config(self, tmp_path): def test_creates_period_from_in_empty_config(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
save_period_to_config( config_path.write_text("")
str(config_path), "2026-06-01", "2026-06-30", "date", True
) save_period_to_config(str(config_path), "2026-06-01")
with open(config_path) as fh: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-01" assert data["period"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
assert "default_from" not in data["period"]
def test_overwrites_existing_last_used(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
def test_overwrites_existing_period_from(self, tmp_path):
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( config_path.write_text("period:\n from: '2026-05-01'\n")
"period:\n last_used:\n from: '2026-05-01'\n to: '2026-05-31'\n"
)
save_period_to_config( save_period_to_config(str(config_path), "2026-06-01")
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
with open(config_path) as fh: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-01" assert data["period"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
def test_dynamic_false_overwrites_defaults(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
config_path.write_text(
"period:\n default_from: '2026-01-01'\n default_to: '2026-01-31'\n"
)
save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", False
)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["default_from"] == "2026-06-01"
assert data["period"]["default_to"] == "2026-06-30"
assert data["period"]["last_used"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
def test_datetime_precision_stores_timestamps(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
save_period_to_config(
str(config_path),
"2026-06-30T09:00:00",
"2026-06-30T12:00:00",
"datetime",
True,
)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-30T09:00:00"
assert data["period"]["last_used"]["to"] == "2026-06-30T12:00:00"
def test_preserves_existing_sections(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
def test_preserves_other_sections_and_keys(self, tmp_path):
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( config_path.write_text(
"redmine:\n" "redmine:\n"
" url: https://example.com\n" " url: https://example.com\n"
" author: Test\n" " author: Test\n"
"email:\n"
" enabled: true\n"
"output:\n" "output:\n"
" dir: /tmp\n" " dir: /tmp\n"
) )
save_period_to_config( save_period_to_config(str(config_path), "2026-06-01")
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
with open(config_path) as fh: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
assert data["redmine"]["url"] == "https://example.com" assert data["redmine"] == {"url": "https://example.com", "author": "Test"}
assert data["redmine"]["author"] == "Test" assert data["email"] == {"enabled": True}
assert data["output"]["dir"] == "/tmp" assert data["output"] == {"dir": "/tmp"}
assert data["period"]["last_used"]["from"] == "2026-06-01" assert data["period"] == {"from": "2026-06-01"}
def test_sets_permissions_0600(self, tmp_path): def test_sets_permissions_0600(self, tmp_path):
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
save_period_to_config( save_period_to_config(str(config_path), "2026-06-01")
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
mode = config_path.stat().st_mode & 0o777 mode = config_path.stat().st_mode & 0o777
assert mode == 0o600 assert mode == 0o600
def test_creates_config_file_and_dir_when_missing(self, tmp_path):
config_path = tmp_path / "nested" / "config.yml"
assert not config_path.exists()
save_period_to_config(str(config_path), "2026-06-01")
assert config_path.exists()
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["from"] == "2026-06-01"