37 Commits
v1.9.0 ... main

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #50
2026-07-10 16:46:30 +07:00
Кокос Артем Николаевич
8992bb922e docs: explain optional period.default_to and today fallback 2026-07-10 16:40:22 +07:00
Кокос Артем Николаевич
608afe08e3 fix: default missing period.default_to to today 2026-07-10 16:33:28 +07:00
Кокос Артем Николаевич
e1862462af feat: add HTML email body for --send (#48)
Add email.html config flag (default false). When enabled, --send
includes an HTML version of the report body generated via HTMLFormatter
alongside the plain-text part in a multipart/alternative message.

- EmailConfig gains html: bool field
- mailer.build_message/send_report accept rows for HTML generation
- CLI passes rows to send_report
- --init-config generates email.html: false
- README.md and docs/CONFIG.md updated

Bump version to 1.10.0.

Closes #48
2026-07-10 15:24:27 +07:00
Кокос Артем Николаевич
863ad50cc3 feat: add report.no_time config option for automatic modes
Add YAML section `report.no_time` that controls `--no-time` behavior
in automatic modes (`--commit`, `--send`). CLI flag `--no-time`
always wins. Manual `--output` ignores the YAML setting.

- Config.get_report_no_time() reads the YAML value (defaults to false)
- cli._resolve_no_time() encapsulates CLI > YAML priority
- --init-config now generates the report section
- docs updated in README.md and docs/CONFIG.md

Closes #49
2026-07-10 14:38:07 +07:00
Кокос Артем Николаевич
5e1c366a60 docs: update README, CONFIG and pyproject.toml for --send feature
- README: add --send flag to features, usage examples, full flag list;
  replace black/isort with ruff in dev section; add email config
  template variables docs
- CONFIG: new email section with all fields documented, --send usage
  examples, error handling and flag compatibility table
- pyproject.toml: remove unused black and isort from dev dependencies
  and their tool configs (project uses ruff for both lint and format)
2026-07-10 12:46:46 +07:00
Кокос Артем Николаевич
0968560090 style: apply ruff format to all source files 2026-07-10 12:39:24 +07:00
Кокос Артем Николаевич
25425901b1 fix: handle save_period_to_config errors, remove duplicate _MockIssue
- cli.py: wrap save_period_to_config in try/except to avoid
  unhandled traceback on disk full / permission denial (I2)
- test_cli.py: remove duplicate _MockIssue class definition (I3)
2026-07-10 12:37:46 +07:00
Кокос Артем Николаевич
b0e353c565 feat: auto-email sending via SMTP (--send flag)
Closes #45

- New redmine_reporter/mailer.py: SMTP email sending with
  {author}/{period} template substitution, MIME attachment
  with correct content-type per file extension
- Config.get_email_config(): returns EmailConfig from YAML
  or None when not configured
- CLI --send flag: sends report after generation, works with
  --output, --commit, or standalone (saves to template path)
- 31 new tests (22 mailer + 6 CLI + 3 config)
- 249/249 tests passing, ruff clean, mypy clean
2026-07-10 12:37:02 +07:00
23 changed files with 3804 additions and 446 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

1
.gitignore vendored
View File

@@ -85,6 +85,7 @@ secrets.json
# Temporary files # Temporary files
*.tmp *.tmp
*.bak *.bak
docs/superpowers
# Just in case # Just in case
.~* .~*

247
README.md
View File

@@ -1,30 +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`, переменные окружения, YAML-конфиг, по умолчанию — текущий месяц.
- Вывод в консоль (таблица / компактный вид). - `--commit`: сохранение отчёта в файл и фиксация периода в конфиге для следующего запуска.
- Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx). - `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо).
- Excel-отчёт с merge-ячейками по проекту/версии, итогами, автошириной, автофильтром и закреплённой шапкой. - YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md).
- Сводка по времени (`--summary`).
- YAML-конфиг (`~/.config/redmine-reporter/config.yml`): шаблон имени файла, путь по умолчанию, период, SMTP.
- Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание.
- `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска.
- Понятные сообщения об ошибках Redmine API (401/403/5xx, таймаут, сеть).
- Загрузка альтернативного `.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
@@ -40,191 +43,40 @@ pip install .
pip install -e ".[dev]" pip install -e ".[dev]"
``` ```
## Настройка ## Быстрый старт
Источники конфигурации (от высшего приоритета к низшему):
```
CLI-флаги > переменные окружения > .env > YAML-конфиг > кодовые дефолты
```
### YAML-конфиг (основной способ)
```bash ```bash
# Сгенерировать YAML из текущего .env # Сгенерировать конфиг ~/.config/redmine-reporter/config.yml
redmine-reporter --init-config redmine-reporter --init-config
# Редактировать под себя # Заполнить redmine.url, redmine.api_key (или ${REDMINE_API_KEY}), redmine.author
vim ~/.config/redmine-reporter/config.yml vim ~/.config/redmine-reporter/config.yml
```
Структура: # Отчёт за текущий месяц в консоль
```yaml
redmine:
url: https://red.eltex.loc
api_key: ${REDMINE_API_KEY}
author: "Кокос А.А."
verify_ssl: true
period:
precision: date # date | datetime
default_from: "2026-06-01"
default_to: "2026-06-30"
dynamic: false
# last_used заполняется --commit (см. docs/CONFIG.md)
output:
dir: ~/reports
filename: "{author}_{from}_{to}.{ext}"
default_format: xlsx
email:
smtp:
host: smtp.example.com
port: 587
user: bot@example.com
password: ${SMTP_PASSWORD}
tls: true
from: bot@example.com
to:
- boss@example.com
subject: "Отчёт {author} за {period}"
```
Шаблон `output.filename` поддерживает `{author}`, `{from}`, `{to}`, `{date}` (DD_MM_YYYY), `{ext}`.
Подробнее: [docs/CONFIG.md](docs/CONFIG.md).
### `.env` (legacy)
```ini
REDMINE_URL=https://red.eltex.loc/
REDMINE_API_KEY=ваш_api_token
REDMINE_AUTHOR=Иванов Иван Иванович
DEFAULT_FROM_DATE=2026-01-01
DEFAULT_TO_DATE=2026-01-31
```
Переменные окружения:
| Переменная | Обязательность | Описание |
| --- | --- | --- |
| `REDMINE_URL` | Да | URL Redmine. |
| `REDMINE_API_KEY` | Да, если нет логина и пароля | Redmine API token. |
| `REDMINE_USER` | Да, если нет токена | Логин Redmine. |
| `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. |
| `REDMINE_AUTHOR` | Нет | Имя автора для отчёта. |
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). |
| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию (`YYYY-MM-DD`). |
| `REDMINE_VERIFY` | Нет | TLS-проверка: `true` / `false` / путь к CA bundle. |
## Использование
```bash
source .venv/bin/activate
```
Отчёт за период по умолчанию:
```bash
redmine-reporter
```
Произвольный период:
```bash
redmine-reporter --date 2026-02-01--2026-02-28
```
Другой пользователь:
```bash
redmine-reporter --user-id 42
redmine-reporter --user-login ivanov
redmine-reporter --user-name "Иванов И.И."
```
Переопределить URL / API-ключ:
```bash
redmine-reporter --url https://red.example.com --api-key ваш_токен
```
Альтернативный `.env`:
```bash
redmine-reporter --config /path/to/.env
```
Компактный / отладочный вывод:
```bash
redmine-reporter --compact
redmine-reporter --debug
```
Экспорт с явным путём:
```bash
redmine-reporter --output report.xlsx
redmine-reporter --output /path/to/report.odt
```
Экспорт — только формат (путь и имя берутся из YAML-шаблона):
```bash
redmine-reporter --output xlsx # → output.dir/отчёт_01_07_2026.xlsx
redmine-reporter --output odt # → output.dir/отчёт_01_07_2026.odt
```
Экспорт — путь без расширения (дописывается `default_format` из конфига):
```bash
redmine-reporter --output /tmp/report # → /tmp/report.xlsx (если default_format: xlsx)
```
Без времени / с разбивкой по активностям:
```bash
redmine-reporter --no-time
redmine-reporter --by-activity
redmine-reporter --by-activity --summary
```
Сводка:
```bash
redmine-reporter --summary
```
Фиксация периода (`--commit`):
```bash
# Сгенерировать, сохранить в файл по шаблону, запомнить период
redmine-reporter --commit
# С явным путём
redmine-reporter --commit --output report.xlsx
# Следующий запуск (без --date) возьмёт следующий период автоматически
redmine-reporter 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-структура, переменные окружения, приоритеты, безопасность.
## Форматы вывода ## Форматы вывода
| Формат | Особенности | | Формат | Особенности |
| --- | --- | | --- | --- |
| **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 колонки остаются, но пустыми и без итогов; в остальных форматах — пустые значения.
## Разработка ## Разработка
@@ -232,16 +84,23 @@ redmine-reporter --commit
```bash ```bash
pytest pytest
ruff check redmine_reporter tests
black --check redmine_reporter tests
isort --check-only redmine_reporter tests isort --check-only redmine_reporter tests
black --check redmine_reporter tests
ruff check redmine_reporter tests
ruff format --check redmine_reporter tests
mypy redmine_reporter mypy redmine_reporter
``` ```
CI — Gitea Actions (`.gitea/workflows/checks.yaml`): все шесть проверок на матрице Python 3.103.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,33 @@
# Настройка redmine-reporter # Настройка redmine-reporter
## Оглавление
- [Источники конфигурации](#источники-конфигурации)
- [YAML-конфиг](#yaml-конфиг)
- [Структура](#структура)
- [`redmine.verify_ssl` — проверка TLS-сертификата](#redmineverify_ssl--проверка-tls-сертификата)
- [`period.precision` — точность периода](#periodprecision--точность-периода)
- [`period.default_to` — необязательное окончание периода](#perioddefault_to--необязательное-окончание-периода)
- [Период по умолчанию — текущий месяц](#период-по-умолчанию--текущий-месяц)
- [`--date` — формат диапазона](#--date--формат-диапазона)
- [`--commit` — автофиксация периода](#--commit--автофиксация-периода)
- [`email` — настройка отправки по почте](#email--настройка-отправки-по-почте)
- [`--send` — отправка отчёта по email](#--send--отправка-отчёта-по-email)
- [`output` — путь и имя файла по умолчанию](#output--путь-и-имя-файла-по-умолчанию)
- [Подстановка переменных окружения](#подстановка-переменных-окружения)
- [`report` — настройки содержимого отчёта](#report--настройки-содержимого-отчёта)
- [`report.status_translation` — перевод статусов](#reportstatus_translation--перевод-статусов)
- [Разрешение выходного пути](#разрешение-выходного-пути)
- [Миграция с `.env` на YAML](#миграция-с-env-на-yaml)
- [Быстрый старт](#быстрый-старт)
- [Что делает `--init-config`](#что-делает---init-config)
- [Флаги миграции](#флаги-миграции)
- [Проверка после миграции](#проверка-после-миграции)
- [Сосуществование `.env` и YAML](#сосуществование-env-и-yaml)
- [Откат](#откат)
- [`.env` (legacy)](#env-legacy)
- [Безопасность](#безопасность)
## Источники конфигурации ## Источники конфигурации
Приоритет, от высшего к низшему: Приоритет, от высшего к низшему:
@@ -8,6 +36,9 @@
CLI-флаги > переменные окружения > .env > YAML > кодовые дефолты CLI-флаги > переменные окружения > .env > YAML > кодовые дефолты
``` ```
Нюанс с `override` при автозагрузке `.env` и `--config` — см.
[Сосуществование `.env` и YAML](#сосуществование-env-и-yaml).
Если значение не задано на верхнем уровне, берётся уровень ниже. `.env` и YAML Если значение не задано на верхнем уровне, берётся уровень ниже. `.env` и YAML
работают одновременно — можно оставить оба, можно удалить `.env` после миграции. работают одновременно — можно оставить оба, можно удалить `.env` после миграции.
@@ -32,6 +63,7 @@ redmine:
period: period:
precision: date precision: date
default_from: "2026-06-01" default_from: "2026-06-01"
# default_to можно не указывать — тогда конец периода будет сегодня
default_to: "2026-06-30" default_to: "2026-06-30"
dynamic: false dynamic: false
last_used: last_used:
@@ -43,7 +75,11 @@ output:
filename: "{author}_{from}_{to}.{ext}" filename: "{author}_{from}_{to}.{ext}"
default_format: xlsx default_format: xlsx
report:
no_time: false
email: email:
html: false
smtp: smtp:
host: smtp.example.com host: smtp.example.com
port: 587 port: 587
@@ -60,15 +96,71 @@ email:
attach: true attach: true
``` ```
### `redmine.verify_ssl` — проверка TLS-сертификата
Управляет проверкой TLS-сертификата Redmine. Семантика едина с переменной
окружения `REDMINE_VERIFY`:
| Значение | Поведение |
|---|---|
| `true` (по умолчанию) | Проверка по системному хранилищу CA операционной системы (через truststore) — корпоративные CA, добавленные в ОС, работают без настройки |
| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM |
| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть |
До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь
`/etc/ssl/certs/ca-certificates.crt`, который существует только в
Debian/Ubuntu. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
одинаково — оба включают проверку по системному хранилищу CA на любой ОС
(через truststore), без привязки к конкретному пути.
### `period.precision` — точность периода ### `period.precision` — точность периода
Определяет, как вычисляется следующий период после фиксации: Определяет, как вычисляется следующий период после фиксации:
- `date` (по умолчанию) — период с точностью до дня. Следующий запуск (после `--commit`) начинается со следующего дня. - `date` (по умолчанию) — период с точностью до дня. Следующий запуск (после `--commit`) начинается со следующего дня.
- `datetime` — период с точностью до секунды. При повторном запуске time entries с `created_on` и `updated_on` ранее `last_used.to` исключаются (AND-логика: запись исключается только если **оба** поля раньше cutoff). Это предотвращает дублирование при отправке отчёта внутри рабочего дня. - `datetime` — период с точностью до секунды. При повторном запуске time entries, у которых `created_on` или `updated_on` раньше `last_used.to`, исключаются: запись сохраняется, только если **оба** поля не раньше cutoff (AND-логика). Записи без дат (оба поля отсутствуют) сохраняются; если задано только одно поле, проверяется оно. Это предотвращает дублирование при отправке отчёта внутри рабочего дня. Cutoff применяется всегда, когда вычислен (`precision: datetime` и задан `last_used.to`) — в том числе при явном `--date`.
`last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется. `last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется.
### `period.default_to` — необязательное окончание периода
Если `period.default_to` не задан, а `period.default_from` задан, инструмент
использует сегодняшнюю дату в качестве конца периода.
```yaml
period:
default_from: "2026-07-01"
# default_to отсутствует → конец периода = сегодня
```
Это предотвращает устаревание периода, когда отчёт генерируется автоматически
(`--send`, `--commit`) без явного `--date`.
Аналогично работает `DEFAULT_TO_DATE`: если переменная не задана, а
`DEFAULT_FROM_DATE` задана, конец периода = сегодня.
Обратное не работает: `period.default_to` без `period.default_from`
`DEFAULT_TO_DATE` без `DEFAULT_FROM_DATE`) игнорируется — период определяется
остальными источниками.
### Период по умолчанию — текущий месяц
Если период не задан ни одним из источников (`--date`, `DEFAULT_FROM_DATE`,
`period.default_from`), отчёт строится за текущий месяц: начало периода —
1-е число текущего месяца, конец — сегодняшняя дата. Период вычисляется
на момент запуска и не хранится в коде или конфиге.
### `--date` — формат диапазона
Флаг `--date` принимает два формата:
- Даты: `YYYY-MM-DD--YYYY-MM-DD` (например, `2026-06-01--2026-06-30`).
- Datetime с секундами: `YYYY-MM-DDTHH:MM:SS--YYYY-MM-DDTHH:MM:SS`
(например, `2026-06-30T09:00:00--2026-06-30T12:00:00`).
Обе границы должны быть в одном формате — смешанная точность отвергается.
Начало позже конца (`start > end`) — ошибка.
### `--commit` — автофиксация периода ### `--commit` — автофиксация периода
Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода. Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода.
@@ -90,6 +182,9 @@ email:
- Произвольный диапазон → та же длительность, начиная со дня после `last_used.to`. - Произвольный диапазон → та же длительность, начиная со дня после `last_used.to`.
- `dynamic: false``--commit` перезаписывает `default_from`/`default_to` на использованный период. - `dynamic: false``--commit` перезаписывает `default_from`/`default_to` на использованный период.
**Нюанс:** при `--commit` YAML-файл перезаписывается целиком через `yaml.dump`
пользовательские комментарии и ручное форматирование в файле теряются.
**Примеры:** **Примеры:**
```bash ```bash
@@ -103,6 +198,125 @@ redmine-reporter --date 2026-06-15--2026-06-20 --commit
redmine-reporter --commit redmine-reporter --commit
``` ```
### `email` — настройка отправки по почте
Секция `email` используется флагом `--send`. Если секция не настроена или `smtp.host`
пуст, `--send` завершится с ошибкой «Email не настроен».
**Все поля:**
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| `smtp.host` | строка | `""` | Адрес SMTP-сервера |
| `smtp.port` | число | `587` | Порт SMTP |
| `smtp.user` | строка | `""` | Логин для аутентификации |
| `smtp.password` | строка | `""` | Пароль (рекомендуется `${SMTP_PASSWORD}`) |
| `smtp.tls` | bool | `true` | Использовать STARTTLS |
| `from` | строка | `""` | Адрес отправителя |
| `to` | список | `[]` | Основные получатели |
| `cc` | список | `[]` | Копия |
| `bcc` | список | `[]` | Скрытая копия (не отображается в заголовках письма) |
| `subject` | строка | `"Отчёт {author} за {period}"` | Тема письма |
| `body_text` | строка | `"Во вложении отчёт."` | Текст письма (plain text) |
| `attach` | bool | `true` | Прикреплять файл отчёта. Если `false` — только текст |
| `html` | bool | `false` | Добавить HTML-версию тела письма (`multipart/alternative`) |
**Подстановки в `subject` и `body_text`:**
| Плейсхолдер | Описание | Пример |
|---|---|---|
| `{author}` | Имя автора из конфига или `--author` | `Кокос А.А.` |
| `{period}` | Строка диапазона дат | `2026-06-01--2026-06-30` |
**MIME-тип вложения** определяется по расширению файла:
| Расширение | MIME-тип |
|---|---|
| `.xlsx` | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` |
| `.odt` | `application/vnd.oasis.opendocument.text` |
| `.csv` | `text/csv` |
| `.html` | `text/html` |
| `.json` | `application/json` |
| `.md` | `text/markdown` |
Неизвестное расширение → `application/octet-stream`.
**Пример конфигурации:**
```yaml
email:
html: false
smtp:
host: smtp.example.com
port: 587
user: bot@example.com
password: ${SMTP_PASSWORD}
tls: true
from: bot@example.com
to:
- boss@example.com
- team-lead@example.com
cc:
- manager@example.com
bcc: []
subject: "Отчёт {author} за {period}"
body_text: "Во вложении отчёт за период {period}."
attach: true
```
### `--send` — отправка отчёта по email
Флаг `--send` отправляет сгенерированный отчёт через SMTP сразу после сохранения
в файл. Требует настроенную секцию `email` в YAML-конфиге.
**Что делает:**
1. Генерирует отчёт как обычно.
2. Сохраняет отчёт в файл:
- Если указан `--output` — по явному пути.
- Если `--output` не указан — по шаблону из `output.dir` / `output.filename`.
3. Формирует MIME-письмо:
- Тема, plain-text тело и вложение (если `attach: true`).
- При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта.
4. Отправляет через SMTP (таймаут 30 секунд). STARTTLS включается только при
`email.smtp.tls: true`; аутентификация — только если задан `email.smtp.user`.
**Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся
на диске, данные не теряются.
**Ошибки SMTP:**
- Нет соединения → `"Не удалось подключиться к SMTP-серверу host:port"`
- Неверный логин/пароль → `"Ошибка аутентификации SMTP. Проверьте логин и пароль."`
- Таймаут → `"Таймаут соединения с SMTP-сервером."`
- Другая ошибка → `"Ошибка отправки письма: <детали>"`
Все ошибки выводятся в stderr, код возврата 1.
**Примеры:**
```bash
# Отправить отчёт за июнь (сохранится по шаблону output.filename)
redmine-reporter --date 2026-06-01--2026-06-30 --send
# С явным путём
redmine-reporter --date 2026-06-01--2026-06-30 --output ~/report.xlsx --send
# Отправить и зафиксировать период
redmine-reporter --date 2026-06-01--2026-06-30 --send --commit
```
**Совместимость с другими флагами:**
| Комбинация | Поведение |
|---|---|
| `--send` | Сохранить по шаблону → отправить |
| `--send --output X` | Сохранить в X → отправить |
| `--send --commit` | Сохранить → отправить → зафиксировать период |
| `--send` с `email.html: true` | Письмо с plain-text + HTML-таблицей |
| `--send` без `email` в конфиге | Ошибка, exit 1 |
| `--send` при ошибке SMTP | Файл сохранён, ошибка в stderr, exit 1 |
### `output` — путь и имя файла по умолчанию ### `output` — путь и имя файла по умолчанию
Секция управляет тем, куда и с каким именем сохраняется отчёт, когда `--output` не содержит полного пути. Секция управляет тем, куда и с каким именем сохраняется отчёт, когда `--output` не содержит полного пути.
@@ -160,6 +374,53 @@ email:
**не запрещены** — если вписать `api_key: "abc123"` напрямую, система примет. **не запрещены** — если вписать `api_key: "abc123"` напрямую, система примет.
Права `0600` — основная защита. Права `0600` — основная защита.
### `report` — настройки содержимого отчёта
Секция управляет тем, что попадает в сгенерированный отчёт.
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта |
| `status_translation` | map[str,str] | `{}` | Переопределение/дополнение перевода статусов |
`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`).
При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`.
CLI-флаг `--no-time` всегда имеет приоритет над YAML.
**Примеры:**
```yaml
report:
no_time: true
```
```bash
# Автоматический режим: время не выводится
redmine-reporter --commit
# Ручной режим: report.no_time игнорируется, время выводится
redmine-reporter --output report.odt
# Ручной режим с явным флагом: время не выводится
redmine-reporter --output report.odt --no-time
```
### `report.status_translation` — перевод статусов
По умолчанию статусы Redmine переводятся встроенным словарём
(`New``В работе`, `Closed``Закрыто` и т.д.). Секция
`report.status_translation` переопределяет отдельные переводы и/или
добавляет новые статусы; не указанные здесь статусы переводятся
встроенным словарём, а совсем неизвестные выводятся как есть.
```yaml
report:
status_translation:
"New": "Новая"
"Wait Release": "Ожидает релиза"
"Custom Status": "Кастомный статус"
```
## Разрешение выходного пути ## Разрешение выходного пути
Функция `resolve_output_path()` определяет итоговый путь к файлу: Функция `resolve_output_path()` определяет итоговый путь к файлу:
@@ -188,7 +449,8 @@ vim ~/.config/redmine-reporter/config.yml
### Что делает `--init-config` ### Что делает `--init-config`
- Читает текущие значения из `.env` и переменных окружения. - Читает текущие значения из `.env` и переменных окружения.
- Формирует YAML со всеми секциями (`redmine`, `period`, `output`, `email`). - Формирует YAML со всеми секциями (`redmine`, `period`, `output`, `report`, `email`).
- В конец файла дописывает закомментированный пример `report.status_translation`.
- Секреты (`REDMINE_API_KEY`, `SMTP_PASSWORD`) записывает как `${VAR}`, если - Секреты (`REDMINE_API_KEY`, `SMTP_PASSWORD`) записывает как `${VAR}`, если
переменная существует, иначе — пустая строка. переменная существует, иначе — пустая строка.
- Создаёт файл с правами `0600`, директорию — с `0700`. - Создаёт файл с правами `0600`, директорию — с `0700`.
@@ -199,7 +461,11 @@ vim ~/.config/redmine-reporter/config.yml
|---|---| |---|---|
| `--init-config` | Создать YAML и выйти | | `--init-config` | Создать YAML и выйти |
| `--init-config --force` | Перезаписать существующий YAML | | `--init-config --force` | Перезаписать существующий YAML |
| `--config-path PATH` | Сохранить YAML по указанному пути (по умолчанию `~/.config/redmine-reporter/config.yml`) | | `--config-path PATH` | Путь к YAML-конфигу: загрузка при запуске, запись при `--init-config` и `--commit` (по умолчанию `~/.config/redmine-reporter/config.yml`) |
Если `DEFAULT_TO_DATE` не задана, а `DEFAULT_FROM_DATE` задана, сгенерированный
YAML будет содержать пустое `default_to`, и при запуске инструмент использует
сегодняшнюю дату.
### Проверка после миграции ### Проверка после миграции
@@ -227,6 +493,10 @@ ls -la ~/.config/redmine-reporter/
YAML работает как базовый слой для всего, что не в .env YAML работает как базовый слой для всего, что не в .env
``` ```
Автозагрузка `.env` (без `--config`) не перебивает реальные переменные
окружения (`override=False`). Исключение — `--config FILE`: указанный `.env`
загружается с `override=True` и перебивает переменные окружения (но не CLI-флаги).
Это safe — если с YAML что-то пойдёт не так, просто положи `.env` обратно. Это safe — если с YAML что-то пойдёт не так, просто положи `.env` обратно.
### Откат ### Откат
@@ -248,7 +518,7 @@ DEFAULT_TO_DATE=2026-06-30
``` ```
Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц
как период, стандартный путь сертификатов, пустой автор). как период, стандартная проверка TLS, пустой автор).
## Безопасность ## Безопасность

304
docs/USER_GUIDE.md Normal file
View File

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

View File

@@ -4,20 +4,19 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "redmine-reporter" name = "redmine-reporter"
version = "1.9.0" version = "1.11.1"
description = "Redmine time-entry based issue reporter for internal use" description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md" readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
license = { text = "MIT" } license = { text = "MIT" }
classifiers = [ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Operating System :: POSIX :: Linux", "Operating System :: POSIX :: Linux",
"Environment :: Console", "Environment :: Console",
] ]
requires-python = ">=3.9" requires-python = ">=3.10"
dependencies = [ dependencies = [
"python-redmine>=2.4.0", "python-redmine>=2.4.0",
"tabulate>=0.9.0", "tabulate>=0.9.0",
@@ -25,15 +24,19 @@ 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]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
"black>=23.0",
"isort>=5.12",
"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]
@@ -46,13 +49,9 @@ include = ["redmine_reporter*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
"redmine_reporter" = ["templates/template.odt"] "redmine_reporter" = ["templates/template.odt"]
[tool.black] [tool.pytest.ini_options]
line-length = 100 testpaths = ["tests"]
target-version = ['py39'] pythonpath = ["."]
[tool.isort]
profile = "black"
multi_line_output = 3
[tool.mypy] [tool.mypy]
warn_unused_configs = true warn_unused_configs = true
@@ -60,3 +59,6 @@ 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.9.0" __version__ = "1.11.1"

View File

@@ -8,14 +8,36 @@ 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
from .config import Config from .config import Config
from .formatters.factory import get_console_formatter, get_formatter_by_extension from .formatters.factory import get_console_formatter, get_formatter_by_extension
from .mailer import send_report
from .report_builder import build_grouped_report, calculate_summary from .report_builder import build_grouped_report, calculate_summary
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
# Маскируем API-ключ Redmine в тексте ошибок (#55):
# - query-параметр key=<значение> в URL (?key=... / &key=...);
# - заголовок X-Redmine-API-Key: <значение>.
_SANITIZE_PATTERNS = [
(re.compile(r"([?&]key=)[^\s&\"')]+", re.IGNORECASE), r"\1***"),
(
re.compile(
r"(X-Redmine-API-Key[\"']?\s*[:=]\s*[\"']?)[^\s\"',}]+", re.IGNORECASE
),
r"\1***",
),
]
def _sanitize_error_text(text: str) -> str:
"""Маскирует API-ключ Redmine в тексте ошибки перед выводом в stderr (#55)."""
for pattern, replacement in _SANITIZE_PATTERNS:
text = pattern.sub(replacement, text)
return text
def parse_date_range(date_arg: str) -> tuple[str, str]: def parse_date_range(date_arg: str) -> tuple[str, str]:
if "--" not in date_arg: if "--" not in date_arg:
@@ -24,28 +46,46 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
from_date, to_date = parts[0].strip(), parts[1].strip() from_date, to_date = parts[0].strip(), parts[1].strip()
date_pattern = r"\d{4}-\d{2}-\d{2}" date_pattern = r"\d{4}-\d{2}-\d{2}"
if not re.fullmatch(date_pattern, from_date) or not re.fullmatch(date_pattern, to_date): datetime_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
if re.fullmatch(date_pattern, from_date) and re.fullmatch(date_pattern, to_date):
fmt = "%Y-%m-%d"
elif re.fullmatch(datetime_pattern, from_date) and re.fullmatch(
datetime_pattern, to_date
):
fmt = "%Y-%m-%dT%H:%M:%S"
else:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try: try:
start = datetime.strptime(from_date, "%Y-%m-%d").date() start = datetime.strptime(from_date, fmt)
end = datetime.strptime(to_date, "%Y-%m-%d").date() end = datetime.strptime(to_date, fmt)
except ValueError as e: except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e raise ValueError("Date range contains invalid calendar date") from e
if start > end: if start > end:
raise ValueError("Date range start must be less than or equal to end") raise ValueError("Date range start must be less than or equal to end")
if fmt == "%Y-%m-%d":
return start.date().isoformat(), end.date().isoformat()
return start.isoformat(), end.isoformat() return start.isoformat(), end.isoformat()
def _run_init_config(config_path: str, force: bool) -> int: def _run_init_config(config_path: str, force: bool) -> int:
"""Создаёт YAML-конфиг из текущих настроек окружения.""" """Создаёт YAML-конфиг из текущих настроек окружения.
Подгружает .env из текущей директории (override=False: реальные
переменные окружения не перебиваются), чтобы --init-config видел
значения, заданные только в .env (#64). Поиск идёт от cwd
(usecwd=True): find_dotenv() без него стартует от директории
cli.py и .env пользователя не находит.
"""
load_dotenv(find_dotenv(usecwd=True), override=False)
path = Path(config_path) path = Path(config_path)
if path.exists() and not force: if path.exists() and not force:
print( print(
f"⚠️ {path} already exists.\n" f" Use --init-config --force to overwrite.", f"⚠️ {path} already exists.\n Use --init-config --force to overwrite.",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1
@@ -69,7 +109,11 @@ def _run_init_config(config_path: str, force: bool) -> int:
"filename": "{author}_{from}_{to}.{ext}", "filename": "{author}_{from}_{to}.{ext}",
"default_format": "xlsx", "default_format": "xlsx",
}, },
"report": {
"no_time": False,
},
"email": { "email": {
"html": False,
"smtp": { "smtp": {
"host": "", "host": "",
"port": 587, "port": 587,
@@ -89,7 +133,19 @@ def _run_init_config(config_path: str, force: bool) -> int:
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:
yaml.dump(data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False) yaml.dump(
data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
)
fh.write(
"\n"
"# report.status_translation: переопределение/дополнение перевода\n"
"# статусов Redmine. Без секции используется встроенный словарь.\n"
"# Пример:\n"
"# report:\n"
"# status_translation:\n"
'# "New": "Новая"\n'
'# "Wait Release": "Ожидает релиза"\n'
)
path.chmod(0o600) path.chmod(0o600)
sections_found = [s for s in data if data[s]] sections_found = [s for s in data if data[s]]
@@ -122,6 +178,100 @@ def _compute_dedup_cutoff() -> Optional[datetime]:
return None return None
def _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool:
"""Возвращает финальное значение no_time.
Приоритет:
1. CLI-флаг --no-time (всегда побеждает).
2. YAML report.no_time (только в автоматических режимах --commit/--send).
3. Иначе False.
"""
if cli_flag:
return True
if is_auto_mode:
return Config.get_report_no_time()
return False
def _save_and_maybe_send(
rows,
output_arg: str,
author: str,
from_date: str,
to_date: str,
no_time: bool,
do_send: bool,
) -> int:
"""Сохраняет отчёт в файл и опционально отправляет по email.
Returns 0 on success, 1 on error.
"""
output_ext = os.path.splitext(output_arg)[1].lower()
if not output_ext:
print(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr,
)
return 1
formatter = get_formatter_by_extension(
output_ext,
author=author,
from_date=from_date,
to_date=to_date,
no_time=no_time,
)
if not formatter:
if output_ext == ".odt":
print(
"❌ odfpy is not installed. Install with: pip install odfpy",
file=sys.stderr,
)
else:
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
print(
f"❌ Неизвестный формат файла: {output_ext!r}. "
f"Поддерживаются: {known_exts}",
file=sys.stderr,
)
return 1
try:
formatter.save(rows, output_arg)
print(f"✅ Report saved to {output_arg}")
except Exception as e:
fmt = output_ext.lstrip(".").upper()
print(f"{fmt} export error: {e}", file=sys.stderr)
return 1
if do_send:
email_config = Config.get_email_config()
if email_config is None:
print(
"❌ Email не настроен. Добавьте секцию 'email' в конфиг "
"(~/.config/redmine-reporter/config.yml).",
file=sys.stderr,
)
return 1
try:
send_report(
email_config,
output_arg,
author,
f"{from_date}--{to_date}",
rows=rows,
)
print(f"📧 Report sent to {', '.join(email_config.to)}")
except RedmineAPIError as e:
print(f"{_sanitize_error_text(e.message)}", file=sys.stderr)
return 1
return 0
def main(argv: Optional[List[str]] = None) -> int: def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="redmine-reporter", prog="redmine-reporter",
@@ -148,7 +298,9 @@ def main(argv: Optional[List[str]] = None) -> int:
"--no-time", action="store_true", help="Do not include spent time into table" "--no-time", action="store_true", help="Do not include spent time into table"
) )
parser.add_argument("--url", help="Override Redmine URL from .env (REDMINE_URL)") parser.add_argument("--url", help="Override Redmine URL from .env (REDMINE_URL)")
parser.add_argument("--api-key", help="Override Redmine API key from .env (REDMINE_API_KEY)") parser.add_argument(
"--api-key", help="Override Redmine API key from .env (REDMINE_API_KEY)"
)
parser.add_argument("--config", help="Path to .env config file") parser.add_argument("--config", help="Path to .env config file")
parser.add_argument("--verbose", action="store_true", help="Enable verbose output") parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
parser.add_argument("--debug", action="store_true", help="Enable debug output") parser.add_argument("--debug", action="store_true", help="Enable debug output")
@@ -200,6 +352,11 @@ def main(argv: Optional[List[str]] = None) -> int:
action="store_true", action="store_true",
help="Save used period as last_used in YAML config and auto-commit to file", help="Save used period as last_used in YAML config and auto-commit to file",
) )
parser.add_argument(
"--send",
action="store_true",
help="Send generated report via email after saving (requires email section in config)",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
# --init-config: обработка до всего остального # --init-config: обработка до всего остального
@@ -215,6 +372,7 @@ def main(argv: Optional[List[str]] = None) -> int:
args.user_name, args.user_name,
args.no_time, args.no_time,
args.by_activity, args.by_activity,
args.send,
] ]
if any(report_flags): if any(report_flags):
print( print(
@@ -274,28 +432,30 @@ def main(argv: Optional[List[str]] = None) -> int:
dedup_before=_compute_dedup_cutoff(), dedup_before=_compute_dedup_cutoff(),
) )
except RedmineAPIError as e: except RedmineAPIError as e:
print(f"{e.message}", file=sys.stderr) print(f"{_sanitize_error_text(e.message)}", file=sys.stderr)
if args.debug and e.original is not None: if (args.verbose or args.debug) and e.original is not None:
logging.exception("Original Redmine API error") logging.exception("Original Redmine API error")
return 1 return 1
except Exception as e: except Exception as e:
print(f"❌ Unexpected error: {e}", file=sys.stderr) print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
if args.verbose or args.debug:
logging.exception("Unexpected error")
return 1 return 1
if issue_hours is None:
print(" No time entries found in the given period.", file=sys.stderr)
return 0
if not issue_hours: if not issue_hours:
print(" No time entries found in the given period.", file=sys.stderr) print(" No time entries found in the given period.", file=sys.stderr)
return 0 return 0
print(f"✅ Total issues: {len(issue_hours)} [{date_arg}]", file=sys.stderr) print(f"✅ Total issues: {len(issue_hours)} [{date_arg}]", file=sys.stderr)
is_auto_mode = args.commit or args.send
no_time = _resolve_no_time(args.no_time, is_auto_mode)
rows = build_grouped_report( rows = build_grouped_report(
issue_hours, issue_hours,
fill_time=not args.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:
@@ -312,65 +472,50 @@ 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)
if args.output: if args.output or args.commit:
output_arg = resolve_output_path( if args.output:
args.output, output_arg = resolve_output_path(
output_dir=Config.get_output_dir(), args.output,
filename_template=Config.get_output_filename(), output_dir=Config.get_output_dir(),
default_format=Config.get_output_default_format(), filename_template=Config.get_output_filename(),
author=Config.get_author(args.author), default_format=Config.get_output_default_format(),
from_date=from_date, author=Config.get_author(args.author),
to_date=to_date, from_date=from_date,
) to_date=to_date,
)
else:
# --commit без --output: используем default_format
default_format = Config.get_output_default_format()
output_arg = resolve_output_path(
default_format,
output_dir=Config.get_output_dir(),
filename_template=Config.get_output_filename(),
default_format=default_format,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
)
if output_arg is None: if output_arg is None:
print( print(
"Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx", "Не удалось определить путь для сохранения отчёта.", file=sys.stderr
file=sys.stderr,
) )
return 1 return 1
output_ext = os.path.splitext(output_arg)[1].lower() ret = _save_and_maybe_send(
rows,
if not output_ext: output_arg,
print(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr,
)
return 1
formatter = get_formatter_by_extension(
output_ext,
author=Config.get_author(args.author), author=Config.get_author(args.author),
from_date=from_date, from_date=from_date,
to_date=to_date, to_date=to_date,
no_time=args.no_time, no_time=no_time,
do_send=args.send,
) )
if ret != 0:
return ret
if not formatter: elif args.send:
if output_ext == ".odt": # --send без --output и --commit: сохраняем по шаблону и отправляем
print(
"❌ odfpy is not installed. Install with: pip install odfpy",
file=sys.stderr,
)
else:
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
print(
f"❌ Неизвестный формат файла: {output_ext!r}. "
f"Поддерживаются: {known_exts}",
file=sys.stderr,
)
return 1
try:
formatter.save(rows, output_arg)
print(f"✅ Report saved to {output_arg}")
except Exception as e:
fmt = output_ext.lstrip(".").upper()
print(f"{fmt} export error: {e}", file=sys.stderr)
return 1
elif args.commit:
default_format = Config.get_output_default_format() default_format = Config.get_output_default_format()
output_arg = resolve_output_path( output_arg = resolve_output_path(
default_format, default_format,
@@ -383,28 +528,22 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
if output_arg is None: if output_arg is None:
print("Не удалось определить путь для сохранения отчёта.", file=sys.stderr) print(
"Не удалось определить путь для сохранения отчёта.", file=sys.stderr
)
return 1 return 1
output_ext = os.path.splitext(output_arg)[1].lower() ret = _save_and_maybe_send(
formatter = get_formatter_by_extension( rows,
output_ext, output_arg,
author=Config.get_author(args.author), author=Config.get_author(args.author),
from_date=from_date, from_date=from_date,
to_date=to_date, to_date=to_date,
no_time=args.no_time, no_time=no_time,
do_send=True,
) )
if ret != 0:
if not formatter: return ret
print(f"Не удалось создать форматтер для {output_ext}", file=sys.stderr)
return 1
try:
formatter.save(rows, output_arg)
print(f"✅ Report saved to {output_arg}")
except Exception as e:
print(f"❌ Export error: {e}", file=sys.stderr)
return 1
else: else:
if args.compact: if args.compact:
@@ -428,16 +567,25 @@ def main(argv: Optional[List[str]] = None) -> int:
dynamic = Config._app.period_dynamic if Config._app else False dynamic = Config._app.period_dynamic if Config._app else False
if precision == "datetime": if precision == "datetime":
from datetime import datetime as dt_mod # Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
# метки aware cutoff для дедупликации.
now = dt_mod.now().isoformat(timespec="seconds") now = datetime.now(timezone.utc).isoformat(timespec="seconds")
from_str = now from_str = now
to_str = now to_str = now
else: else:
from_str = from_date from_str = from_date
to_str = to_date to_str = to_date
save_period_to_config(args.config_path, from_str, to_str, precision, dynamic) try:
save_period_to_config(
args.config_path, from_str, to_str, precision, dynamic
)
except Exception as e:
print(
f"Не удалось сохранить период в конфиг: {e}",
file=sys.stderr,
)
return 1
print( print(
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}", f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
file=sys.stderr, file=sys.stderr,

View File

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

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, timedelta
from pathlib import Path from pathlib import Path
from typing import Union from typing import Dict, Union
import yaml import yaml
from dotenv import load_dotenv from dotenv import load_dotenv
from .yaml_config import check_file_permissions, resolve_env_vars from .yaml_config import check_file_permissions, resolve_env_vars
load_dotenv(override=False)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt" DEFAULT_REDMINE_VERIFY: Union[bool, str] = True
FALSE_VALUES = {"0", "false", "no", "off"} FALSE_VALUES = {"0", "false", "no", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"} TRUE_VALUES = {"1", "true", "yes", "on"}
@@ -38,6 +37,7 @@ class EmailConfig:
subject: str = "Отчёт {author} за {period}" subject: str = "Отчёт {author} за {period}"
body_text: str = "Во вложении отчёт." body_text: str = "Во вложении отчёт."
attach: bool = True attach: bool = True
html: bool = False
@dataclass @dataclass
@@ -61,6 +61,8 @@ class AppConfig:
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_status_translation: Dict[str, str] = field(default_factory=dict)
email: EmailConfig = field(default_factory=EmailConfig) email: EmailConfig = field(default_factory=EmailConfig)
@classmethod @classmethod
@@ -86,6 +88,7 @@ class AppConfig:
"redmine", "redmine",
"period", "period",
"output", "output",
"report",
"email", "email",
} }
for key in raw: for key in raw:
@@ -106,7 +109,12 @@ class AppConfig:
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") or "xlsx", output_default_format=cls._resolve_str(raw, "output", "default_format")
or "xlsx",
report_no_time=cls._resolve_bool(raw, "report", "no_time"),
report_status_translation=cls._resolve_str_dict(
raw, "report", "status_translation"
),
email=cls._resolve_email(raw), email=cls._resolve_email(raw),
) )
@@ -136,6 +144,43 @@ class AppConfig:
return "" return ""
return str(value) return str(value)
@staticmethod
def _resolve_str_dict(raw: dict, section: str, key: str) -> Dict[str, str]:
"""Читает вложенный mapping строк из YAML-секции.
Не-mapping значение логируется с warning и игнорируется.
Ключи и значения приводятся к str, ${VAR} в значениях резолвится.
Записи со значением null или не-scalar (dict/list) пропускаются
с warning — иначе null стал бы строкой "None", а dict — repr.
"""
section_value = raw.get(section)
if not isinstance(section_value, dict):
return {}
value = section_value.get(key)
if value is None:
return {}
if not isinstance(value, dict):
logger.warning(
"Config %s.%s must be a mapping, got %s — ignoring",
section,
key,
type(value).__name__,
)
return {}
result: Dict[str, str] = {}
for k, v in value.items():
if v is None or isinstance(v, (dict, list)):
logger.warning(
"Config %s.%s entry %r must be a scalar, got %s — skipping",
section,
key,
k,
type(v).__name__,
)
continue
result[str(k)] = resolve_env_vars(str(v))
return result
@staticmethod @staticmethod
def _resolve_bool(raw: dict, section: str, key: str) -> bool: def _resolve_bool(raw: dict, section: str, key: str) -> bool:
value = raw.get(section, {}).get(key) value = raw.get(section, {}).get(key)
@@ -151,17 +196,23 @@ class AppConfig:
@classmethod @classmethod
def _resolve_verify(cls, raw: dict) -> Union[bool, str]: def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
"""Семантика verify_ssl, единая с REDMINE_VERIFY из env:
- true (bool или строка) → True — стандартная проверка TLS (requests);
- false (bool или строка) → False — проверка отключена;
- любая другая строка → путь к CA-bundle как есть.
"""
value = raw.get("redmine", {}).get("verify_ssl") value = raw.get("redmine", {}).get("verify_ssl")
if value is None: if value is None:
return DEFAULT_REDMINE_VERIFY return DEFAULT_REDMINE_VERIFY
if isinstance(value, bool): if isinstance(value, bool):
return DEFAULT_REDMINE_VERIFY if value else False return value
if isinstance(value, str): if isinstance(value, str):
normalized = value.lower() normalized = value.lower()
if normalized in FALSE_VALUES: if normalized in FALSE_VALUES:
return False return False
if normalized in TRUE_VALUES: if normalized in TRUE_VALUES:
return DEFAULT_REDMINE_VERIFY return True
return resolve_env_vars(value) return resolve_env_vars(value)
return DEFAULT_REDMINE_VERIFY return DEFAULT_REDMINE_VERIFY
@@ -189,6 +240,7 @@ class AppConfig:
cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт." cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт."
), ),
attach=cls._safe_bool(email_raw.get("attach"), True), attach=cls._safe_bool(email_raw.get("attach"), True),
html=cls._safe_bool(email_raw.get("html"), False),
) )
@staticmethod @staticmethod
@@ -236,7 +288,13 @@ class Config:
@classmethod @classmethod
def load_yaml(cls, path: str) -> None: def load_yaml(cls, path: str) -> None:
"""Загружает YAML-конфиг. Не бросает исключений при отсутствии файла.""" """Загружает конфигурацию приложения.
Сначала подгружает .env из текущей директории (override=False:
переменные окружения не перебиваются), затем читает YAML-конфиг.
Не бросает исключений при отсутствии файла.
"""
load_dotenv(override=False)
cls._app = AppConfig.from_yaml(path) cls._app = AppConfig.from_yaml(path)
@classmethod @classmethod
@@ -343,12 +401,44 @@ class Config:
return cls._app.output_default_format or "xlsx" return cls._app.output_default_format or "xlsx"
return "xlsx" return "xlsx"
@classmethod
def get_report_no_time(cls) -> bool:
"""Возвращает report.no_time из YAML-конфига (по умолчанию False)."""
if cls._app:
return cls._app.report_no_time
return False
@classmethod
def get_status_translation(cls) -> Dict[str, str]:
"""Полный словарь перевода статусов: встроенный + переопределения из YAML.
Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется.
Импорт ленивый: config.py не должен тянуть redminelib при импорте.
"""
from .report_builder import STATUS_TRANSLATION
overrides = cls._app.report_status_translation if cls._app else {}
return {**STATUS_TRANSLATION, **overrides}
@classmethod
def get_email_config(cls) -> "EmailConfig | None":
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""
if cls._app is None:
return None
email = cls._app.email
if not email.smtp.host:
return None
return email
@classmethod @classmethod
def get_default_date_range(cls) -> str: def get_default_date_range(cls) -> str:
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip() from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
to_env = os.getenv("DEFAULT_TO_DATE", "").strip() to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
if from_env and to_env: today = date.today()
return f"{from_env}--{to_env}" today_str = today.isoformat()
if from_env:
return f"{from_env}--{to_env or today_str}"
if ( if (
cls._app cls._app
@@ -363,22 +453,30 @@ class Config:
) )
return f"{nf}--{nt}" return f"{nf}--{nt}"
if cls._app and cls._app.period_default_from and cls._app.period_default_to: if cls._app and cls._app.period_default_from:
return f"{cls._app.period_default_from}--{cls._app.period_default_to}" default_to = cls._app.period_default_to or today_str
return f"{cls._app.period_default_from}--{default_to}"
today = date.today()
start = today.replace(day=1) start = today.replace(day=1)
if today.month == 12: return f"{start.isoformat()}--{today_str}"
next_month = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month = today.replace(month=today.month + 1, day=1)
end = next_month - timedelta(days=1)
return f"{start.isoformat()}--{end.isoformat()}"
@classmethod @classmethod
def validate(cls) -> None: def validate(cls) -> None:
if not cls.get_redmine_url(): url = cls.get_redmine_url()
if not url:
raise ValueError("REDMINE_URL is required (set via env or .env)") raise ValueError("REDMINE_URL is required (set via env or .env)")
if not url.lower().startswith("https://"):
raise ValueError(
"REDMINE_URL must use HTTPS: the API key is sent in request "
"headers and requires TLS"
)
if cls.get_redmine_verify() is False:
print(
"⚠️ TLS certificate verification is disabled "
"(REDMINE_VERIFY=false / verify_ssl: false): connection is "
"vulnerable to MITM attacks",
file=sys.stderr,
)
if cls.get_redmine_api_key(): if cls.get_redmine_api_key():
return return
if not (cls.get_redmine_user() and cls.get_redmine_password()): if not (cls.get_redmine_user() and cls.get_redmine_password()):
@@ -387,7 +485,9 @@ class Config:
) )
def compute_next_period(last_from: str, last_to: str, precision: str) -> tuple[str, str]: 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. """Compute the next report period based on the last committed period.
- For a full calendar month → next full calendar month. - For a full calendar month → next full calendar month.

View File

@@ -15,7 +15,9 @@ from .base import Formatter
class ODTFormatter(Formatter): class ODTFormatter(Formatter):
"""Форматтер для экспорта в ODT.""" """Форматтер для экспорта в ODT."""
def __init__(self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs): def __init__(
self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs
):
""" """
Инициализирует форматтер с параметрами для шапки отчета. Инициализирует форматтер с параметрами для шапки отчета.
""" """
@@ -28,7 +30,11 @@ class ODTFormatter(Formatter):
Форматирует данные в объект OpenDocument. Форматирует данные в объект OpenDocument.
""" """
with resources.files("redmine_reporter").joinpath("templates/template.odt").open("rb") as f: with (
resources.files("redmine_reporter")
.joinpath("templates/template.odt")
.open("rb") as f
):
doc = load(f) doc = load(f)
# Удаляем все текстовые параграфы из шаблона, оставляя только # Удаляем все текстовые параграфы из шаблона, оставляя только
@@ -52,7 +58,9 @@ class ODTFormatter(Formatter):
# Стиль ячеек # Стиль ячеек
cell_style_name = "TableCellStyle" cell_style_name = "TableCellStyle"
cell_style = Style(name=cell_style_name, family="table-cell") cell_style = Style(name=cell_style_name, family="table-cell")
cell_props = TableCellProperties(padding="0.04in", border="0.05pt solid #000000") cell_props = TableCellProperties(
padding="0.04in", border="0.05pt solid #000000"
)
cell_style.addElement(cell_props) cell_style.addElement(cell_props)
doc.automaticstyles.addElement(cell_style) doc.automaticstyles.addElement(cell_style)
@@ -100,7 +108,9 @@ class ODTFormatter(Formatter):
# в остальных — covered-cell для валидности ODF (#13) # в остальных — covered-cell для валидности ODF (#13)
if first_version_in_project and first_row_in_version: if first_version_in_project and first_row_in_version:
cell_project = TableCell(stylename=cell_style_name) cell_project = TableCell(stylename=cell_style_name)
cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) cell_project.setAttribute(
"numberrowsspanned", str(total_project_rows)
)
p = P(stylename=para_style_name, text=project) p = P(stylename=para_style_name, text=project)
cell_project.addElement(p) cell_project.addElement(p)
row.addElement(cell_project) row.addElement(cell_project)
@@ -111,7 +121,9 @@ class ODTFormatter(Formatter):
# в остальных — covered-cell для валидности ODF (#13) # в остальных — covered-cell для валидности ODF (#13)
if first_row_in_version: if first_row_in_version:
cell_version = TableCell(stylename=cell_style_name) cell_version = TableCell(stylename=cell_style_name)
cell_version.setAttribute("numberrowsspanned", str(row_span_version)) cell_version.setAttribute(
"numberrowsspanned", str(row_span_version)
)
p = P(stylename=para_style_name, text=version) p = P(stylename=para_style_name, text=version)
cell_version.addElement(p) cell_version.addElement(p)
row.addElement(cell_version) row.addElement(cell_version)

View File

@@ -19,8 +19,12 @@ class XLSXFormatter(Formatter):
и числовой столбец с часами для удобного суммирования. и числовой столбец с часами для удобного суммирования.
""" """
_HEADER_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") _HEADER_FILL = PatternFill(
_TOTAL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") start_color="D9E1F2", end_color="D9E1F2", fill_type="solid"
)
_TOTAL_FILL = PatternFill(
start_color="FFF2CC", end_color="FFF2CC", fill_type="solid"
)
_BORDER = Border( _BORDER = Border(
left=Side(style="thin"), left=Side(style="thin"),
right=Side(style="thin"), right=Side(style="thin"),
@@ -35,12 +39,17 @@ 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.title = "Report"
ws = wb.create_sheet("Report")
else:
ws.title = "Report"
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Hours", "Spent Time"] headers = [
"Project",
"Version",
"Issue ID",
"Subject",
"Status",
"Hours",
"Spent Time",
]
ws.append(headers) ws.append(headers)
self._style_header_row(ws, headers) self._style_header_row(ws, headers)
@@ -89,7 +98,10 @@ class XLSXFormatter(Formatter):
) )
self._style_total_row(ws, current_row, bold=False) self._style_total_row(ws, current_row, bold=False)
ws.merge_cells( ws.merge_cells(
start_row=current_row, start_column=2, end_row=current_row, end_column=5 start_row=current_row,
start_column=2,
end_row=current_row,
end_column=5,
) )
current_row += 1 current_row += 1
@@ -99,7 +111,8 @@ class XLSXFormatter(Formatter):
if not self.no_time: if not self.no_time:
project_hours = sum( project_hours = sum(
sum(r.get("hours", 0.0) for r in task_rows) for task_rows in versions.values() sum(r.get("hours", 0.0) for r in task_rows)
for task_rows in versions.values()
) )
ws.append( ws.append(
[ [
@@ -114,7 +127,10 @@ class XLSXFormatter(Formatter):
) )
self._style_total_row(ws, current_row, bold=True) self._style_total_row(ws, current_row, bold=True)
ws.merge_cells( ws.merge_cells(
start_row=current_row, start_column=1, end_row=current_row, end_column=5 start_row=current_row,
start_column=1,
end_row=current_row,
end_column=5,
) )
current_row += 1 current_row += 1
project_totals[project] = project_hours project_totals[project] = project_hours
@@ -137,7 +153,9 @@ class XLSXFormatter(Formatter):
] ]
) )
self._style_total_row(ws, current_row, bold=True) self._style_total_row(ws, current_row, bold=True)
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=5) ws.merge_cells(
start_row=current_row, start_column=1, end_row=current_row, end_column=5
)
for start, end in project_ranges: for start, end in project_ranges:
ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1) ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1)
@@ -167,7 +185,9 @@ class XLSXFormatter(Formatter):
cell.font = Font(bold=True) cell.font = Font(bold=True)
cell.fill = self._HEADER_FILL cell.fill = self._HEADER_FILL
cell.border = self._BORDER cell.border = self._BORDER
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) cell.alignment = Alignment(
horizontal="center", vertical="center", wrap_text=True
)
def _style_data_row(self, ws: Worksheet, row: int) -> None: def _style_data_row(self, ws: Worksheet, row: int) -> None:
for col_idx in range(1, 8): for col_idx in range(1, 8):
@@ -190,7 +210,15 @@ class XLSXFormatter(Formatter):
def _apply_column_widths(self, ws: Worksheet) -> None: def _apply_column_widths(self, ws: Worksheet) -> None:
# Минимальные ширины по умолчанию # Минимальные ширины по умолчанию
widths: Dict[int, float] = {1: 18.0, 2: 16.0, 3: 12.0, 4: 45.0, 5: 14.0, 6: 10.0, 7: 14.0} widths: Dict[int, float] = {
1: 18.0,
2: 16.0,
3: 12.0,
4: 45.0,
5: 14.0,
6: 10.0,
7: 14.0,
}
for row in ws.iter_rows(min_row=2, max_row=ws.max_row): for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
for col_idx, cell in enumerate(row, start=1): for col_idx, cell in enumerate(row, start=1):

152
redmine_reporter/mailer.py Normal file
View File

@@ -0,0 +1,152 @@
"""Отправка сгенерированного отчёта по email через SMTP."""
import os
import smtplib
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Dict, List
from .client import RedmineAPIError
from .config import EmailConfig
from .types import ReportRow
SMTP_TIMEOUT = 30
MIME_TYPES: Dict[str, str] = {
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".odt": "application/vnd.oasis.opendocument.text",
".csv": "text/csv",
".html": "text/html",
".json": "application/json",
".md": "text/markdown",
}
def _resolve_mime_type(file_path: str) -> str:
"""Определяет MIME-тип по расширению файла."""
ext = os.path.splitext(file_path)[1].lower()
return MIME_TYPES.get(ext, "application/octet-stream")
def _utf8_text_part(text: str, subtype: str = "plain") -> MIMEText:
"""Создаёт текстовую MIME-часть с 8bit UTF-8 телом.
Кодировка применяется точечно к части письма, без глобальной мутации
реестра email.charset: payload хранится как UTF-8 (surrogateescape),
а Content-Transfer-Encoding выставляется в 8bit, чтобы не-ASCII текст
(например, русский) передавался литерально, а не в base64.
"""
part = MIMEText("", subtype, "utf-8")
part.set_payload(text.encode("utf-8").decode("ascii", "surrogateescape"))
part.replace_header("Content-Transfer-Encoding", "8bit")
return part
def _build_html_body(rows: List[ReportRow]) -> str:
"""Генерирует HTML-версию тела письма через HTMLFormatter."""
from .formatters.html import HTMLFormatter
formatter = HTMLFormatter()
return formatter.format(rows)
def build_message(
email_config: EmailConfig,
file_path: str,
author: str,
period: str,
rows: List[ReportRow],
) -> MIMEMultipart:
"""Формирует MIME-письмо с подстановками, телами и вложением."""
subject = email_config.subject.replace("{author}", author).replace(
"{period}", period
)
body = email_config.body_text.replace("{author}", author).replace(
"{period}", period
)
msg = MIMEMultipart()
msg["Subject"] = subject
msg["From"] = email_config.from_
msg["To"] = ", ".join(email_config.to)
if email_config.cc:
msg["Cc"] = ", ".join(email_config.cc)
# Тела письма: plain-text всегда, HTML по флагу
body_container = MIMEMultipart("alternative")
body_container.attach(_utf8_text_part(body))
if email_config.html:
html_body = _build_html_body(rows)
body_container.attach(_utf8_text_part(html_body, "html"))
msg.attach(body_container)
if email_config.attach:
try:
with open(file_path, "rb") as fh:
attachment = MIMEApplication(fh.read())
except OSError:
raise RedmineAPIError(
f"Не удалось прочитать файл отчёта: {file_path}"
) from None
attachment.add_header(
"Content-Disposition",
"attachment",
filename=os.path.basename(file_path),
)
mime_type = _resolve_mime_type(file_path)
attachment.set_type(mime_type)
msg.attach(attachment)
return msg
def send_report(
email_config: EmailConfig,
file_path: str,
author: str,
period: str,
rows: List[ReportRow],
) -> None:
"""Отправляет сгенерированный отчёт по email через SMTP.
Args:
email_config: Настройки SMTP и письма.
file_path: Путь к файлу отчёта для вложения.
author: Имя автора (для подстановки в тему/тело).
period: Строка периода (для подстановки в тему/тело).
rows: Строки отчёта (для HTML-версии тела письма).
Raises:
RedmineAPIError: При любой ошибке соединения или отправки.
"""
smtp_cfg = email_config.smtp
msg = build_message(email_config, file_path, author, period, rows)
all_recipients = (
list(email_config.to) + list(email_config.cc) + list(email_config.bcc)
)
try:
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=SMTP_TIMEOUT) as server:
if smtp_cfg.tls:
server.starttls()
if smtp_cfg.user:
server.login(smtp_cfg.user, smtp_cfg.password)
server.send_message(
msg, from_addr=email_config.from_, to_addrs=all_recipients
)
except smtplib.SMTPAuthenticationError:
raise RedmineAPIError(
"Ошибка аутентификации SMTP. Проверьте логин и пароль."
) from None
except TimeoutError:
raise RedmineAPIError("Таймаут соединения с SMTP-сервером.") from None
except smtplib.SMTPException as exc:
raise RedmineAPIError(f"Ошибка отправки письма: {exc}") from exc
except OSError as exc:
raise RedmineAPIError(
f"Не удалось подключиться к SMTP-серверу {smtp_cfg.host}:{smtp_cfg.port}"
) from exc

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,10 +43,21 @@ def build_grouped_report(
Предусловие: issue_hours должен быть отсортирован по (project, version). Предусловие: issue_hours должен быть отсортирован по (project, version).
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода. Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
status_translation: перевод статусов; None — встроенный STATUS_TRANSLATION.
Переданный словарь ЗАМЕНЯЕТ встроенный полностью (без merge): статус,
отсутствующий в нём, выводится как есть (passthrough); пустой dict —
все статусы выводятся как есть.
""" """
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе # Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)) issue_hours = sorted(
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
)
translation = (
status_translation if status_translation is not None else STATUS_TRANSLATION
)
rows: List[ReportRow] = [] rows: List[ReportRow] = []
prev_project: str = "" prev_project: str = ""
@@ -56,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:
@@ -67,7 +79,9 @@ def build_grouped_report(
time_text = "" time_text = ""
display_project = project if project != prev_project else "" display_project = project if project != prev_project else ""
display_version = version if (project != prev_project or version != prev_version) else "" display_version = (
version if (project != prev_project or version != prev_version) else ""
)
rows.append( rows.append(
cast( cast(
@@ -122,7 +136,9 @@ def calculate_summary(
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()}, **{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
} }
if by_activity: if by_activity:
result.update({f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()}) result.update(
{f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()}
)
return result return result

View File

@@ -21,7 +21,9 @@ def resolve_env_vars(value: str) -> str:
var_name = match.group(1) var_name = match.group(1)
env_value = os.environ.get(var_name) env_value = os.environ.get(var_name)
if env_value is None: if env_value is None:
logger.warning("Environment variable %s is not set, using empty string", var_name) logger.warning(
"Environment variable %s is not set, using empty string", var_name
)
return "" return ""
return env_value return env_value
@@ -182,5 +184,7 @@ def save_period_to_config(
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:
yaml.dump(raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False) yaml.dump(
raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
)
path.chmod(0o600) path.chmod(0o600)

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):
@@ -157,7 +169,9 @@ def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
assert "key" not in kwargs assert "key" not in kwargs
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True) @mock.patch.dict(
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
)
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_custom_verify_path(mock_redmine_class): def test_fetch_uses_custom_verify_path(mock_redmine_class):
mock_redmine = mock_redmine_class.return_value mock_redmine = mock_redmine_class.return_value
@@ -170,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):
@@ -273,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 = []
@@ -285,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):
@@ -390,7 +554,9 @@ def test_fetch_mounts_retry_adapter(mock_redmine_class):
assert "http://" in prefixes assert "http://" in prefixes
# Проверяем retry-конфигурацию адаптера # Проверяем retry-конфигурацию адаптера
https_adapter = next(call.args[1] for call in mount_calls if call.args[0] == "https://") https_adapter = next(
call.args[1] for call in mount_calls if call.args[0] == "https://"
)
max_retries = https_adapter.max_retries max_retries = https_adapter.max_retries
assert max_retries.total == 3 assert max_retries.total == 3
assert 429 in max_retries.status_forcelist assert 429 in max_retries.status_forcelist
@@ -422,7 +588,9 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
ids_str = kwargs.get("issue_id", "") ids_str = kwargs.get("issue_id", "")
call_chunks.append(ids_str) call_chunks.append(ids_str)
ids = [int(x) for x in ids_str.split(",")] ids = [int(x) for x in ids_str.split(",")]
return [mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids] return [
mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids
]
mock_redmine.issue.filter.side_effect = issue_filter_side_effect mock_redmine.issue.filter.side_effect = issue_filter_side_effect
@@ -473,7 +641,9 @@ 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("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) 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) == 1 assert len(result) == 1
@@ -482,7 +652,9 @@ def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
@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(mock_redmine_class): def test_dedup_filters_entries_with_old_created_even_if_updated_recently(
mock_redmine_class,
):
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика).""" """Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -500,7 +672,9 @@ def test_dedup_filters_entries_with_old_created_even_if_updated_recently(mock_re
mock_redmine.time_entry.filter.return_value = [e1] mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = [] mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is None assert result is None
@@ -531,7 +705,9 @@ def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
mock_issue1.status = "New" 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=dedup_cutoff) 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) == 1 assert len(result) == 1
@@ -563,7 +739,9 @@ def test_dedup_entries_without_created_on_are_kept(mock_redmine_class):
mock_issue1.status = "New" 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=dedup_cutoff) 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) == 1 assert len(result) == 1
@@ -589,7 +767,9 @@ def test_dedup_handles_string_created_on(mock_redmine_class):
mock_redmine.time_entry.filter.return_value = [e1] mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = [] mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is None assert result is None
@@ -665,9 +845,311 @@ def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
mock_issue_akiy.status = "New" mock_issue_akiy.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy] 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) 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 len(result) == 2
ids = {r[0].id for r in result} ids = {r[0].id for r in result}
assert ids == {20, 30} assert ids == {20, 30}
# -- Пагинация time entries (>100 записей) --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
"""Time entries приходят страницами по 100 — часы агрегируются по всем страницам."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
def make_entry(issue_id, hours):
e = mock.MagicMock()
e.issue.id = issue_id
e.hours = hours
return e
# Эмулируем ленивую пагинацию redminelib ResourceSet: записи отдаются
# генератором порциями ("страницами" по 100), как при догрузке с сервера.
# Если клиент прочитает только первую страницу, агрегация будет неверной.
pages = [
[make_entry(1, 0.5) for _ in range(100)],
[make_entry(2, 1.0) for _ in range(100)],
[make_entry(1, 0.5) for _ in range(50)],
]
def paged_entries():
for page in pages:
yield from page
mock_redmine.time_entry.filter.return_value = paged_entries()
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_issue2.project = "P"
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time("2026-01-01", "2026-03-31")
assert result is not None
hours_by_id = {issue.id: hours for issue, hours, _ in result}
# issue 1: 100×0.5 (стр. 1) + 50×0.5 (стр. 3) = 75.0
assert hours_by_id[1] == 75.0
# issue 2: 100×1.0 (стр. 2) = 100.0
assert hours_by_id[2] == 100.0
# -- #58: дедупликация при precision=datetime с naive created_on --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class):
"""python-redmine отдаёт naive created_on; cutoff aware (UTC) — фильтрация не падает.
Ожидаемое поведение после фикса #58: naive datetime трактуется как UTC,
старые записи отсекаются, новые сохраняются.
"""
from datetime import datetime, timezone
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
e_old = mock.MagicMock()
e_old.issue.id = 1
e_old.hours = 2.0
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0) # naive, как из python-redmine
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
e_new = mock.MagicMock()
e_new.issue.id = 2
e_new.hours = 1.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0) # naive
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_issue2.project = "P"
mock_issue2.subject = "T"
mock_issue2.status = "New"
# API возвращает обе задачи: issue 1 попадёт в результат, только если
# её time entry не отсечён дедупликацией. Корректный фикс #58 отсекает
# e_old (created_on/updated_on < cutoff) -> [2]; неправильный фикс
# "погасить TypeError и оставить всё" даст [1, 2] и тест упадёт.
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None
assert [r[0].id for r in result] == [2]
# -- #58: нормализация datetime к aware UTC --
class TestParseDatetimeNormalization:
"""_parse_datetime всегда возвращает aware datetime; naive трактуется как UTC."""
def test_naive_datetime_treated_as_utc(self):
from datetime import datetime, timezone
from redmine_reporter.client import _parse_datetime
result = _parse_datetime(datetime(2026, 7, 1, 10, 0, 0))
assert result == datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc)
def test_aware_datetime_kept(self):
from datetime import datetime, timedelta, timezone
from redmine_reporter.client import _parse_datetime
aware = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone(timedelta(hours=3)))
assert _parse_datetime(aware) is aware
def test_naive_iso_string_treated_as_utc(self):
from datetime import timezone
from redmine_reporter.client import _parse_datetime
result = _parse_datetime("2026-07-01T10:00:00")
assert result is not None
assert result.tzinfo == timezone.utc
def test_aware_iso_string_keeps_offset(self):
from datetime import timedelta
from redmine_reporter.client import _parse_datetime
result = _parse_datetime("2026-07-01T10:00:00+03:00")
assert result is not None
assert result.utcoffset() == timedelta(hours=3)
def test_none_and_garbage_return_none(self):
from redmine_reporter.client import _parse_datetime
assert _parse_datetime(None) is None
assert _parse_datetime("not a date") is None
assert _parse_datetime(123) is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_accepts_naive_cutoff(mock_redmine_class):
"""Naive dedup_before трактуется как UTC — сравнение не падает (#58)."""
from datetime import datetime
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
naive_cutoff = datetime(2026, 7, 1, 12, 0, 0) # naive == UTC
e_old = mock.MagicMock()
e_old.issue.id = 1
e_old.hours = 2.0
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0)
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
e_new = mock.MagicMock()
e_new.issue.id = 2
e_new.hours = 1.0
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0)
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=naive_cutoff
)
assert result is not None
assert [r[0].id for r in result] == [2]
# -- #61: предупреждения о молчаливой потере данных --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_warns_about_missing_issues(mock_redmine_class, capsys):
"""Задачи, не вернувшиеся из issue.filter, попадают в warning в stderr (#61).
Отчёт при этом строится по доступным данным — это предупреждение, не ошибка.
"""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
def make_entry(issue_id, hours):
e = mock.MagicMock()
e.issue.id = issue_id
e.hours = hours
return e
mock_redmine.time_entry.filter.return_value = [
make_entry(1, 1.0),
make_entry(2, 2.5),
make_entry(3, 0.75),
]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue1.subject = "T"
mock_issue1.status = "New"
# Задачи 2 и 3 не вернулись (нет прав / удалены)
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
assert [r[0].id for r in result] == [1]
captured = capsys.readouterr()
assert "2" in captured.err
assert "3" in captured.err
assert "3.25" in captured.err # сумма потерянных часов 2.5 + 0.75
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_no_warning_when_all_issues_returned(mock_redmine_class, capsys):
"""Если все задачи вернулись, stderr остаётся чистым (#61)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
e = mock.MagicMock()
e.issue.id = 1
e.hours = 1.0
mock_redmine.time_entry.filter.return_value = [e]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
captured = capsys.readouterr()
assert captured.err == ""
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_warns_when_activities_lookup_fails(mock_redmine_class, capsys):
"""Сбой справочника активностей — warning в stderr, отчёт строится (#61)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.enumeration.filter.side_effect = Exception("enumeration boom")
e = mock.MagicMock()
e.issue.id = 1
e.hours = 1.0
e.activity.id = 9
e.activity.name = "Dev"
mock_redmine.time_entry.filter.return_value = [e]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31", by_activity=True)
assert result is not None
assert result[0][2] == {"Dev": 1.0} # fallback на имя из самого entry
captured = capsys.readouterr()
assert "activit" in captured.err.lower()
def test_load_time_entry_activities_returns_empty_on_failure(capsys):
"""_load_time_entry_activities возвращает {} и пишет warning при сбое (#61)."""
from redmine_reporter.client import _load_time_entry_activities
redmine = mock.MagicMock()
redmine.enumeration.filter.side_effect = Exception("enumeration boom")
assert _load_time_entry_activities(redmine) == {}
captured = capsys.readouterr()
assert "activit" in captured.err.lower()

View File

@@ -5,7 +5,7 @@ from unittest import mock
import pytest import pytest
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY, AppConfig, Config from redmine_reporter.config import AppConfig, Config, EmailConfig
@mock.patch.dict( @mock.patch.dict(
@@ -74,26 +74,89 @@ def test_get_default_date_range_from_env():
assert Config.get_default_date_range() == "2026-01-01--2026-01-31" assert Config.get_default_date_range() == "2026-01-01--2026-01-31"
@mock.patch.dict(
os.environ,
{"DEFAULT_FROM_DATE": "2026-01-01"},
clear=True,
)
def test_get_default_date_range_from_env_without_to():
"""Если DEFAULT_TO_DATE не задан, конец периода — сегодня."""
from datetime import date
today = date.today().isoformat()
assert Config.get_default_date_range() == f"2026-01-01--{today}"
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_get_default_date_range_fallback(): def test_get_default_date_range_fallback():
"""Если даты не заданы -- используется текущий месяц.""" """Если даты не заданы -- используется текущий месяц с начала до сегодня."""
from datetime import date, timedelta from datetime import date
today = date.today() today = date.today()
start = today.replace(day=1) start = today.replace(day=1)
if today.month == 12:
next_month = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month = today.replace(month=today.month + 1, day=1)
end = next_month - timedelta(days=1)
result = Config.get_default_date_range() result = Config.get_default_date_range()
assert result == f"{start.isoformat()}--{end.isoformat()}" assert result == f"{start.isoformat()}--{today.isoformat()}"
# -- #56: дефолтный период — текущий месяц (детерминированные тесты) --
class TestDefaultPeriodIsCurrentMonth:
"""Без --date, env и YAML период = с 1-го числа текущего месяца по сегодня."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_mid_month(self):
from datetime import date
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 3, 14)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
Config._app = None
assert Config.get_default_date_range() == "2026-03-01--2026-03-14"
@mock.patch.dict(os.environ, {}, clear=True)
def test_first_day_of_month(self):
from datetime import date
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 5, 1)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
Config._app = None
assert Config.get_default_date_range() == "2026-05-01--2026-05-01"
@mock.patch.dict(os.environ, {}, clear=True)
def test_yaml_default_from_falls_back_to_today(self):
"""default_from задан, default_to нет → конец периода = сегодня."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("period:\n default_from: '2026-04-01'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 4, 20)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
assert Config.get_default_date_range() == "2026-04-01--2026-04-20"
@mock.patch.dict(os.environ, {"DEFAULT_FROM_DATE": "2026-02-01"}, clear=True)
def test_env_from_without_to_falls_back_to_today(self):
"""DEFAULT_FROM_DATE задана, DEFAULT_TO_DATE нет → конец = сегодня."""
from datetime import date
Config._app = None
with mock.patch("redmine_reporter.config.date") as mock_date:
mock_date.today.return_value = date(2026, 2, 10)
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
assert Config.get_default_date_range() == "2026-02-01--2026-02-10"
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_get_redmine_verify_default(): def test_get_redmine_verify_default():
assert Config.get_redmine_verify() == DEFAULT_REDMINE_VERIFY """Без явной настройки проверка TLS включена (стандартные CA requests)."""
Config._app = None
assert Config.get_redmine_verify() is True
@pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"]) @pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"])
@@ -113,17 +176,103 @@ def test_get_redmine_verify_custom_path():
assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem" assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem"
# -- #54: REDMINE_URL обязан использовать HTTPS --
class TestConfigRequiresHttpsUrl:
"""Config.validate() отклоняет URL без TLS: API-ключ идёт в заголовках."""
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "http://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_http_url_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "red.eltex.loc", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_url_without_scheme_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "ftp://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_non_http_scheme_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_https_url_accepted(self):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "HTTPS://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_uppercase_scheme_accepted(self):
Config.validate()
# -- #57: предупреждение при отключённой проверке TLS --
class TestWarnWhenTlsVerificationDisabled:
"""verify_ssl: false должен давать видимый warning в stderr (риск MITM)."""
_ENV = {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"}
def _validate_env(self, verify: str):
return {**self._ENV, "REDMINE_VERIFY": verify}
def test_verify_false_warns_in_stderr(self, capsys):
with mock.patch.dict(os.environ, self._validate_env("false"), clear=True):
Config.validate()
err = capsys.readouterr().err
assert "⚠️" in err
assert "TLS" in err or "SSL" in err
def test_verify_true_no_warning(self, capsys):
with mock.patch.dict(os.environ, self._validate_env("true"), clear=True):
Config.validate()
assert capsys.readouterr().err == ""
def test_verify_ca_path_no_warning(self, capsys):
with mock.patch.dict(
os.environ, self._validate_env("/tmp/redmine-ca.pem"), clear=True
):
Config.validate()
assert capsys.readouterr().err == ""
def test_verify_default_no_warning(self, capsys):
with mock.patch.dict(os.environ, self._ENV, clear=True):
Config.validate()
assert capsys.readouterr().err == ""
# -- #15: .env не должен переопределять переменные окружения -- # -- #15: .env не должен переопределять переменные окружения --
@mock.patch("dotenv.load_dotenv") @mock.patch("redmine_reporter.config.load_dotenv")
def test_env_var_takes_priority_over_dotenv(mock_load): def test_env_var_takes_priority_over_dotenv(mock_load):
"""load_dotenv вызывается с override=False — env vars не перебиваются .env.""" """load_dotenv вызывается с override=False — env vars не перебиваются .env."""
import importlib Config.load_yaml("/nonexistent/config.yml")
from redmine_reporter import config as cfg_mod
importlib.reload(cfg_mod)
mock_load.assert_called_once_with(override=False) mock_load.assert_called_once_with(override=False)
@@ -252,16 +401,57 @@ class TestAppConfigFromYaml:
assert cfg.redmine_api_key == "secret-token" assert cfg.redmine_api_key == "secret-token"
def test_verify_ssl_true_returns_default_ca_path(self): def test_verify_ssl_true_returns_true(self):
"""verify_ssl: true → DEFAULT_REDMINE_VERIFY (путь), не Python True.""" """verify_ssl: true → bool True (стандартная проверка TLS)."""
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml" yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n verify_ssl: true\n") yaml_path.write_text("redmine:\n verify_ssl: true\n")
cfg = AppConfig.from_yaml(yaml_path) cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify == DEFAULT_REDMINE_VERIFY assert cfg.redmine_verify is True
assert cfg.redmine_verify is not True # не бул!
@pytest.mark.parametrize("value", ["true", "True", "yes", "on", "1"])
def test_verify_ssl_true_string_returns_true(self, value):
"""Строковые true-значения в YAML → bool True, как и из env."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify is True
def test_verify_ssl_custom_path_returns_path(self):
"""Явный путь к CA-bundle в YAML передаётся как есть."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n verify_ssl: /etc/ssl/my-ca.pem\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify == "/etc/ssl/my-ca.pem"
@pytest.mark.parametrize("value", ["false", "False", "no", "off", "0"])
def test_verify_ssl_false_string_returns_false(self, value):
"""Строковые false-значения в YAML → bool False."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(f"redmine:\n verify_ssl: '{value}'\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify is False
def test_verify_ssl_missing_returns_true(self):
"""Без verify_ssl в YAML — проверка TLS включена (True)."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("redmine:\n url: https://x.com/\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.redmine_verify is True
def test_verify_ssl_false_returns_false(self): def test_verify_ssl_false_returns_false(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -328,7 +518,9 @@ class TestConfigYamlFallback:
assert Config.get_redmine_url() == "https://cli-override.example.com" assert Config.get_redmine_url() == "https://cli-override.example.com"
@mock.patch.dict(os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True) @mock.patch.dict(
os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True
)
def test_env_beats_yaml(self): def test_env_beats_yaml(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml" yaml_path = Path(tmp) / "config.yml"
@@ -501,7 +693,9 @@ class TestComputeNextPeriod:
def test_datetime_precision_moves_by_seconds(self): def test_datetime_precision_moves_by_seconds(self):
from redmine_reporter.config import compute_next_period from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime") nf, nt = compute_next_period(
"2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime"
)
assert nf == "2026-06-30T12:00:01" assert nf == "2026-06-30T12:00:01"
assert nt == "2026-06-30T15:00:01" assert nt == "2026-06-30T15:00:01"
@@ -557,3 +751,325 @@ class TestDefaultDateRangeWithLastUsed:
Config._app = AppConfig.from_yaml(yaml_path) Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range() result = Config.get_default_date_range()
assert result == "2026-04-01--2026-04-15" assert result == "2026-04-01--2026-04-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_falls_back_to_today_when_default_to_missing(self):
"""Если default_from задан, а default_to нет — конец периода сегодня."""
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-04-01'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
today = date.today().isoformat()
result = Config.get_default_date_range()
assert result == f"2026-04-01--{today}"
class TestGetEmailConfig:
"""Tests for Config.get_email_config()."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_email_config_from_yaml(self):
"""get_email_config() возвращает EmailConfig из YAML."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"email:\n"
" smtp:\n"
" host: smtp.example.com\n"
" port: 587\n"
" user: bot@example.com\n"
" password: secret\n"
" tls: true\n"
" from: bot@example.com\n"
" to:\n"
" - boss@example.com\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
cfg = Config.get_email_config()
assert cfg is not None
assert cfg.smtp.host == "smtp.example.com"
assert cfg.smtp.port == 587
assert cfg.smtp.user == "bot@example.com"
assert cfg.smtp.password == "secret"
assert cfg.smtp.tls is True
assert cfg.from_ == "bot@example.com"
assert cfg.to == ["boss@example.com"]
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_email_config_returns_none_when_no_yaml(self):
"""Без YAML-конфига get_email_config() возвращает None."""
Config._app = None
assert Config.get_email_config() is None
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_email_config_returns_none_when_no_host(self):
"""С YAML но без smtp.host — возвращает None."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("email:\n smtp:\n host: ''\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_email_config() is None
class TestEmailConfigHtml:
"""Tests for EmailConfig.html field."""
def test_email_config_html_from_yaml(self):
"""email.html: true загружается из YAML."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"email:\n html: true\n smtp:\n host: smtp.example.com\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.email.html is True
def test_email_config_html_defaults_to_false(self):
"""email.html по умолчанию False."""
assert EmailConfig().html is False
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("email:\n smtp:\n host: smtp.example.com\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.email.html is False
def test_email_config_html_false_from_yaml(self):
"""email.html: false явно загружается из YAML."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"email:\n html: false\n smtp:\n host: smtp.example.com\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.email.html is False
def test_email_config_html_invalid_string_defaults_to_false(self):
"""email.html со строкой 'invalid' приводится к False."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"email:\n html: 'invalid'\n smtp:\n host: smtp.example.com\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.email.html is False
class TestReportNoTime:
"""Tests for Config.get_report_no_time()."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_report_no_time_from_yaml(self):
"""report.no_time: true загружается из YAML."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n no_time: true\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_report_no_time() is True
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_report_no_time_defaults_to_false(self):
"""Без YAML-конфига report.no_time по умолчанию False."""
Config._app = None
assert Config.get_report_no_time() is False
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_report_no_time_from_yaml_false(self):
"""report.no_time: false явно загружается из YAML."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n no_time: false\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_report_no_time() is False
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_report_no_time_invalid_type_defaults_to_false(self):
"""report.no_time со строковым значением приводится к False."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n no_time: 'invalid'\n")
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_report_no_time() is False
# ---------------------------------------------------------------------------
# #65: report.status_translation — перевод статусов из YAML
# ---------------------------------------------------------------------------
class TestStatusTranslationFromYaml:
"""Tests for report.status_translation loading in AppConfig.from_yaml()."""
def test_status_translation_loaded_from_yaml(self):
"""report.status_translation заполняет поле AppConfig (override + новый)."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n"
" status_translation:\n"
' New: "Новая"\n'
' "Custom Status": "Кастом"\n'
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {
"New": "Новая",
"Custom Status": "Кастом",
}
def test_status_translation_missing_defaults_to_empty(self):
"""Без секции report.status_translation поле пустое."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n no_time: true\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {}
def test_status_translation_non_mapping_warns_and_ignored(self, caplog):
"""status_translation не-mapping (строка) → warning в лог, поле {}."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n status_translation: 'just a string'\n")
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {}
assert "status_translation" in caplog.text
@mock.patch.dict(os.environ, {"STATUS_RU": "На проверке"}, clear=True)
def test_status_translation_env_var_resolved(self):
"""${VAR} в значении перевода резолвится из окружения."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n status_translation:\n Testing: ${STATUS_RU}\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Testing": "На проверке"}
def test_status_translation_null_value_skipped_with_warning(self, caplog):
"""Значение null пропускается с warning, а не превращается в 'None'."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n status_translation:\n New:\n Closed: Закрыто\n"
)
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Closed": "Закрыто"}
assert "status_translation" in caplog.text
assert "New" in caplog.text
def test_status_translation_nested_dict_skipped_with_warning(self, caplog):
"""Вложенный dict в значении пропускается с warning, а не в repr."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n"
" status_translation:\n"
" New:\n"
" nested: value\n"
" Closed: Закрыто\n"
)
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Closed": "Закрыто"}
assert "status_translation" in caplog.text
assert "New" in caplog.text
def test_status_translation_int_value_coerced_to_str(self):
"""Scalar-значения (int) по-прежнему приводятся к str."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n status_translation:\n New: 42\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"New": "42"}
class TestGetStatusTranslation:
"""Tests for Config.get_status_translation()."""
def test_no_yaml_returns_builtin(self):
"""Без YAML-конфига возвращается встроенный словарь."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = None
try:
assert Config.get_status_translation() == STATUS_TRANSLATION
finally:
Config._app = None
def test_empty_overrides_returns_builtin(self):
"""Пустые overrides — результат равен встроенному словарю."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = AppConfig()
try:
assert Config.get_status_translation() == STATUS_TRANSLATION
finally:
Config._app = None
def test_override_and_extend(self):
"""Override существующего статуса + добавление нового."""
Config._app = AppConfig(
report_status_translation={"New": "Новая", "Custom Status": "Кастом"}
)
try:
translation = Config.get_status_translation()
finally:
Config._app = None
assert translation["New"] == "Новая"
assert translation["Custom Status"] == "Кастом"
# остальные встроенные переводы не тронуты
assert translation["Closed"] == "Закрыто"
def test_result_is_a_copy(self):
"""Мутация результата не трогает модульный STATUS_TRANSLATION."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = AppConfig(report_status_translation={"New": "Новая"})
try:
translation = Config.get_status_translation()
translation["New"] = "ИЗМЕНЕНО"
translation["Junk"] = "мусор"
finally:
Config._app = None
assert STATUS_TRANSLATION["New"] == "В работе"
assert "Junk" not in STATUS_TRANSLATION

View File

@@ -134,7 +134,9 @@ def odt_formatter():
) )
), ),
): ):
yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31") yield ODTFormatter(
author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31"
)
# -- Тесты упаковки formatters как полноценного пакета -- # -- Тесты упаковки formatters как полноценного пакета --
@@ -171,7 +173,11 @@ def _simulate_missing_odfpy():
saved = {} saved = {}
for key in list(sys.modules.keys()): for key in list(sys.modules.keys()):
if key == "odf" or key.startswith("odf.") or key == "redmine_reporter.formatters.odt": if (
key == "odf"
or key.startswith("odf.")
or key == "redmine_reporter.formatters.odt"
):
saved[key] = sys.modules.pop(key) saved[key] = sys.modules.pop(key)
return saved return saved
@@ -390,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
@@ -450,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 форматтера --
@@ -474,7 +525,9 @@ def test_odt_empty_author_no_garbage_in_header(fake_rows):
) )
), ),
): ):
formatter = ODTFormatter(author="", from_date="2026-01-01", to_date="2026-01-31") formatter = ODTFormatter(
author="", from_date="2026-01-01", to_date="2026-01-31"
)
doc = formatter.format(fake_rows) doc = formatter.format(fake_rows)
from odf.text import P from odf.text import P
@@ -501,7 +554,9 @@ def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
) )
), ),
): ):
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31") formatter = ODTFormatter(
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
)
output_file = tmp_path / "report.odt" output_file = tmp_path / "report.odt"
formatter.save(fake_rows, str(output_file)) formatter.save(fake_rows, str(output_file))
@@ -537,7 +592,9 @@ def test_odt_has_covered_cells_for_spans(fake_rows):
) )
), ),
): ):
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31") formatter = ODTFormatter(
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
)
doc = formatter.format(fake_rows) doc = formatter.format(fake_rows)
from odf.table import CoveredTableCell from odf.table import CoveredTableCell

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"

333
tests/test_mailer.py Normal file
View File

@@ -0,0 +1,333 @@
import smtplib
from unittest import mock
import pytest
from redmine_reporter.client import RedmineAPIError
from redmine_reporter.config import EmailConfig, SmtpConfig
from redmine_reporter.mailer import build_message, send_report
def _make_email_config(**overrides) -> EmailConfig:
"""Создаёт EmailConfig с минимальными валидными настройками."""
smtp = SmtpConfig(
host=overrides.pop("smtp_host", "smtp.example.com"),
port=overrides.pop("smtp_port", 587),
user=overrides.pop("smtp_user", "bot@example.com"),
password=overrides.pop("smtp_password", "secret"),
tls=overrides.pop("smtp_tls", True),
)
return EmailConfig(
smtp=smtp,
from_=overrides.pop("from_", "bot@example.com"),
to=overrides.pop("to", ["boss@example.com"]),
cc=overrides.pop("cc", []),
bcc=overrides.pop("bcc", []),
subject=overrides.pop("subject", "Отчёт {author} за {period}"),
body_text=overrides.pop("body_text", "Во вложении отчёт."),
attach=overrides.pop("attach", True),
**overrides,
)
class TestBuildMessage:
"""Тесты формирования MIME-письма."""
def test_subject_substitution(self):
"""{author} и {period} подставляются в тему."""
cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False)
msg = build_message(
cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30", []
)
assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30"
def test_body_substitution(self):
"""{author} и {period} подставляются в тело."""
cfg = _make_email_config(
body_text="Автор: {author}, период: {period}", attach=False
)
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", [])
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
assert len(plain_parts) == 1
payload = plain_parts[0].get_payload(decode=True).decode("utf-8")
assert "Автор: Иванов, период: Q1" in payload
def test_wire_format_is_8bit_utf8(self):
"""Сериализация в байты (как в smtplib.send_message) — 8bit UTF-8."""
from email.generator import BytesGenerator
from io import BytesIO
cfg = _make_email_config(body_text="Автор: {author}", attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
buf = BytesIO()
BytesGenerator(buf, policy=msg.policy.clone(linesep="\r\n")).flatten(msg)
data = buf.getvalue()
assert b"Content-Transfer-Encoding: 8bit" in data
assert "Автор: Иванов".encode("utf-8") in data
def test_plain_part_uses_8bit_utf8(self):
"""Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть."""
cfg = _make_email_config(
body_text="Автор: {author}, период: {period}", attach=False
)
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
plain = [p for p in msg.walk() if p.get_content_type() == "text/plain"][0]
assert plain["Content-Transfer-Encoding"] == "8bit"
assert (
plain.get_payload(decode=True).decode("utf-8")
== "Автор: Иванов, период: Q1"
)
def test_from_header(self):
cfg = _make_email_config(from_="sender@example.com", attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
assert msg["From"] == "sender@example.com"
def test_to_header(self):
cfg = _make_email_config(to=["a@x.com", "b@x.com"], attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
assert msg["To"] == "a@x.com, b@x.com"
def test_cc_header(self):
cfg = _make_email_config(cc=["cc@x.com"], attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
assert msg["Cc"] == "cc@x.com"
def test_bcc_not_in_headers(self):
"""BCC не должен появляться в заголовках письма."""
cfg = _make_email_config(bcc=["hidden@x.com"], attach=False)
msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", [])
assert "Bcc" not in msg
def test_attachment_present_when_attach_true(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake xlsx content")
cfg = _make_email_config(attach=True)
msg = build_message(cfg, str(report), "A", "P", [])
assert len(msg.get_payload()) == 2
def test_no_attachment_when_attach_false(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake xlsx content")
cfg = _make_email_config(attach=False)
msg = build_message(cfg, str(report), "A", "P", [])
assert len(msg.get_payload()) == 1
@pytest.mark.parametrize(
"ext,expected_mime",
[
(
".xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
(".odt", "application/vnd.oasis.opendocument.text"),
(".csv", "text/csv"),
(".html", "text/html"),
(".json", "application/json"),
(".md", "text/markdown"),
],
)
def test_mime_type_by_extension(self, ext, expected_mime, tmp_path):
report = tmp_path / f"report{ext}"
report.write_text("content")
cfg = _make_email_config()
msg = build_message(cfg, str(report), "A", "P", [])
attachment_part = msg.get_payload()[1]
assert attachment_part.get_content_type() == expected_mime
def test_missing_attachment_file_raises_error(self, tmp_path):
"""Если файл вложения не существует — RedmineAPIError."""
cfg = _make_email_config()
missing = str(tmp_path / "nonexistent.xlsx")
with pytest.raises(RedmineAPIError, match="Не удалось прочитать файл"):
build_message(cfg, missing, "A", "P", [])
def test_html_part_present_when_html_true(self, tmp_path):
"""При email.html: true письмо содержит multipart/alternative с HTML."""
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(html=True)
rows = [
{
"project": "Project",
"version": "1.0",
"issue_id": 1,
"subject": "Task",
"status_ru": "В работе",
"time_text": "",
"hours": 2.0,
}
]
msg = build_message(cfg, str(report), "A", "P", rows)
alternatives = [
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
]
assert len(alternatives) == 1
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
assert len(html_parts) == 1
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
assert len(plain_parts) == 1
def test_no_html_part_when_html_false(self, tmp_path):
"""При email.html: false письмо содержит только plain-text."""
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(html=False)
msg = build_message(cfg, str(report), "A", "P", [])
html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"]
assert len(html_parts) == 0
plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"]
assert len(plain_parts) == 1
def test_html_part_contains_report_data(self, tmp_path):
"""HTML-часть содержит данные отчёта."""
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(html=True)
rows = [
{
"project": "Project X",
"version": "2.0",
"issue_id": 42,
"subject": "Important task",
"status_ru": "В работе",
"time_text": "3ч 30м",
"hours": 3.5,
}
]
msg = build_message(cfg, str(report), "A", "P", rows)
html_part = [p for p in msg.walk() if p.get_content_type() == "text/html"][0]
payload = html_part.get_payload(decode=True).decode("utf-8")
assert "Project X" in payload
assert "Important task" in payload
def test_plain_text_before_html_in_alternative(self, tmp_path):
"""В multipart/alternative plain-text идёт перед HTML."""
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(html=True)
rows = [
{
"project": "P",
"version": "V",
"issue_id": 1,
"subject": "S",
"status_ru": "В работе",
"time_text": "",
"hours": 1.0,
}
]
msg = build_message(cfg, str(report), "A", "P", rows)
alternative = [
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
][0]
payloads = alternative.get_payload()
assert payloads[0].get_content_type() == "text/plain"
assert payloads[1].get_content_type() == "text/html"
class TestSendReport:
"""Тесты отправки письма."""
def test_send_success(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
send_report(cfg, str(report), "Кокос А.Н.", "2026-06-01--2026-06-30", [])
mock_smtp_class.assert_called_once_with("smtp.example.com", 587, timeout=30)
mock_smtp.starttls.assert_called_once()
mock_smtp.login.assert_called_once_with("bot@example.com", "secret")
mock_smtp.send_message.assert_called_once()
def test_send_no_tls(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(smtp_tls=False)
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
send_report(cfg, str(report), "A", "P", [])
mock_smtp.starttls.assert_not_called()
def test_send_connection_error(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")):
with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"):
send_report(cfg, str(report), "A", "P", [])
def test_send_auth_error(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
mock_smtp.login.side_effect = smtplib.SMTPAuthenticationError(
535, b"Bad auth"
)
with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"):
send_report(cfg, str(report), "A", "P", [])
def test_send_timeout_error(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=TimeoutError()):
with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"):
send_report(cfg, str(report), "A", "P", [])
def test_send_generic_smtp_error(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
mock_smtp.send_message.side_effect = smtplib.SMTPException(
"Something went wrong"
)
with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"):
send_report(cfg, str(report), "A", "P", [])
def test_send_includes_cc_and_bcc(self, tmp_path):
report = tmp_path / "report.xlsx"
report.write_text("fake content")
cfg = _make_email_config(to=["a@x.com"], cc=["cc@x.com"], bcc=["bcc@x.com"])
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
send_report(cfg, str(report), "A", "P", [])
call_args = mock_smtp.send_message.call_args
msg = call_args.args[0]
assert msg["To"] == "a@x.com"
assert msg["Cc"] == "cc@x.com"

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

@@ -217,7 +217,9 @@ class TestSavePeriodToConfig:
from redmine_reporter.yaml_config import save_period_to_config 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(str(config_path), "2026-06-01", "2026-06-30", "date", True) save_period_to_config(
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)
@@ -233,10 +235,12 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( config_path.write_text(
"period:\n" " last_used:\n" " from: '2026-05-01'\n" " to: '2026-05-31'\n" "period:\n last_used:\n from: '2026-05-01'\n to: '2026-05-31'\n"
) )
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True) save_period_to_config(
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)
@@ -251,10 +255,12 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( config_path.write_text(
"period:\n" " default_from: '2026-01-01'\n" " default_to: '2026-01-31'\n" "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) save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", False
)
with open(config_path) as fh: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
@@ -299,7 +305,9 @@ class TestSavePeriodToConfig:
" dir: /tmp\n" " dir: /tmp\n"
) )
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True) save_period_to_config(
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)
@@ -314,7 +322,9 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True) save_period_to_config(
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