From 27d21ef639c6ba45ee6f5f4bd2ddc177bc531772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Wed, 30 Sep 2026 12:37:00 +0700 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE?= =?UTF-8?q?=D0=B4=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20period.from=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20last=5Fused;=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=B4=D1=83=D0=BF=20=D1=83=D0=B4=D0=B0=D0=BB=D1=91=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалены last_used, precision, dynamic и дедупликация по created_on/updated_on. Период задаётся одним полем period.from (env FROM_DATE), окно -- от этой даты до сегодня. После успешного --commit/--send начало окна сдвигается на конец отчёта; datetime-границы --date приводятся к полным дням. Схема конфига ломающая: старые ключи игнорируются, миграция описана в документации. --- README.md | 8 +- docs/CONFIG.md | 173 ++++++----- docs/USER_GUIDE.md | 73 ++--- redmine_reporter/cli.py | 133 ++++----- redmine_reporter/client.py | 70 +---- redmine_reporter/config.py | 167 ++--------- redmine_reporter/yaml_config.py | 22 +- tests/test_cli.py | 493 ++++++++++++++++++-------------- tests/test_client.py | 438 +++------------------------- tests/test_config.py | 398 +++++++++----------------- tests/test_yaml_config.py | 116 +++----- 11 files changed, 726 insertions(+), 1365 deletions(-) diff --git a/README.md b/README.md index 4e5f96b..bfa40eb 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ CLI-инструмент для генерации отчётов по зада - Группировка задач по проекту и версии, перевод статусов на русский язык. - Вывод в консоль (таблица или компактный вид) и экспорт в ODT, CSV, Markdown, HTML, JSON, XLSX. - Разбивка времени по типам активности (`--by-activity`), сводка (`--summary`), скрытие времени (`--no-time`). -- Гибкий выбор периода: `--date`, переменные окружения, YAML-конфиг, по умолчанию — текущий месяц. -- `--commit`: сохранение отчёта в файл и фиксация периода в конфиге для следующего запуска. -- `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо). +- Гибкий выбор периода: `--date`, env `FROM_DATE`, YAML `period.from`, по умолчанию — текущий месяц. +- `--commit`: сохранение отчёта в файл и авто-сдвиг начала периода в конфиге для следующего запуска. +- `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо) с авто-сдвигом начала периода при успешной отправке. - YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md). ## Установка @@ -55,7 +55,7 @@ vim ~/.config/redmine-reporter/config.yml # Отчёт за текущий месяц в консоль redmine-reporter -# Сохранить в файл и зафиксировать период для следующего запуска +# Сохранить в файл и сдвинуть начало периода для следующего запуска redmine-reporter --commit ``` diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 5b9acfa..d1e4e82 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -6,11 +6,10 @@ - [YAML-конфиг](#yaml-конфиг) - [Структура](#структура) - [`redmine.verify_ssl` — проверка TLS-сертификата](#redmineverify_ssl--проверка-tls-сертификата) - - [`period.precision` — точность периода](#periodprecision--точность-периода) - - [`period.default_to` — необязательное окончание периода](#perioddefault_to--необязательное-окончание-периода) - - [Период по умолчанию — текущий месяц](#период-по-умолчанию--текущий-месяц) + - [`period.from` — начало окна отчёта](#periodfrom--начало-окна-отчёта) - [`--date` — формат диапазона](#--date--формат-диапазона) - - [`--commit` — автофиксация периода](#--commit--автофиксация-периода) + - [Авто-сдвиг начала периода](#авто-сдвиг-начала-периода) + - [Миграция старых ключей периода](#миграция-старых-ключей-периода) - [`email` — настройка отправки по почте](#email--настройка-отправки-по-почте) - [`--send` — отправка отчёта по email](#--send--отправка-отчёта-по-email) - [`output` — путь и имя файла по умолчанию](#output--путь-и-имя-файла-по-умолчанию) @@ -61,14 +60,7 @@ redmine: verify_ssl: true period: - precision: date - default_from: "2026-06-01" - # default_to можно не указывать — тогда конец периода будет сегодня - default_to: "2026-06-30" - dynamic: false - last_used: - from: "2026-06-30T09:00:00" - to: "2026-06-30T12:00:00" + from: "2026-06-01" output: dir: ~/reports @@ -113,91 +105,111 @@ Debian/Ubuntu. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env ра одинаково — оба включают проверку по системному хранилищу CA на любой ОС (через truststore), без привязки к конкретному пути. -### `period.precision` — точность периода +### `period.from` — начало окна отчёта -Определяет, как вычисляется следующий период после фиксации: - -- `date` (по умолчанию) — период с точностью до дня. Следующий запуск (после `--commit`) начинается со следующего дня. -- `datetime` — период с точностью до секунды. При повторном запуске time entries, у которых `created_on` или `updated_on` раньше `last_used.to`, исключаются: запись сохраняется, только если **оба** поля не раньше cutoff (AND-логика). Записи без дат (оба поля отсутствуют) сохраняются; если задано только одно поле, проверяется оно. Это предотвращает дублирование при отправке отчёта внутри рабочего дня. Cutoff применяется всегда, когда вычислен (`precision: datetime` и задан `last_used.to`) — в том числе при явном `--date`. - -`last_used.from` / `last_used.to` записываются автоматически при `--commit`. Вручную редактировать не требуется. - -### `period.default_to` — необязательное окончание периода - -Если `period.default_to` не задан, а `period.default_from` задан, инструмент -использует сегодняшнюю дату в качестве конца периода. +Единственный ключ периода в YAML. Дата в формате `YYYY-MM-DD`: ```yaml period: - default_from: "2026-07-01" - # default_to отсутствует → конец периода = сегодня + from: "2026-06-01" ``` -Это предотвращает устаревание периода, когда отчёт генерируется автоматически -(`--send`, `--commit`) без явного `--date`. +Окно отчёта — `from..сегодня`: конец окна всегда сегодняшняя дата и отдельно +не настраивается. Переменная окружения `FROM_DATE` задаёт то же начало и имеет +приоритет над YAML: -Аналогично работает `DEFAULT_TO_DATE`: если переменная не задана, а -`DEFAULT_FROM_DATE` задана, конец периода = сегодня. +``` +--date > FROM_DATE (env) > period.from (YAML) > 1-е число текущего месяца +``` -Обратное не работает: `period.default_to` без `period.default_from` (и -`DEFAULT_TO_DATE` без `DEFAULT_FROM_DATE`) игнорируется — период определяется -остальными источниками. +Разрешение начала окна: -### Период по умолчанию — текущий месяц +| Условие | Начало окна | +|---|---| +| `from` не задан (`FROM_DATE` и `period.from` пусты) | 1-е число текущего месяца | +| `from` — корректная дата | `from` | +| `from` позже сегодняшней даты | сегодня (окно «сегодня--сегодня») | +| `from` некорректна (не `YYYY-MM-DD`) | warning в stderr + 1-е число текущего месяца | -Если период не задан ни одним из источников (`--date`, `DEFAULT_FROM_DATE`, -`period.default_from`), отчёт строится за текущий месяц: начало периода — -1-е число текущего месяца, конец — сегодняшняя дата. Период вычисляется -на момент запуска и не хранится в коде или конфиге. +Граничный день (день коммита) становится началом следующего окна и потому +перечитывается: уже отправленные записи этого дня повторятся в следующем +отчёте. Это осознанное следствие отсутствия дедупликации; зато поздние +записи, добавленные в Redmine в день коммита после отправки, не теряются. +Повторный запуск `--commit`/`--send` в тот же день даёт окно +«сегодня--сегодня». + +`period.from` записывается автоматически при `--commit`/`--send`; вручную +редактировать не требуется. ### `--date` — формат диапазона -Флаг `--date` принимает два формата: +Флаг `--date` задаёт диапазон явно и переопределяет все остальные источники. +Каждая граница — дата `YYYY-MM-DD` или datetime `YYYY-MM-DDTHH:MM:SS`: -- Даты: `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`). +- `2026-06-01--2026-06-30`; +- `2026-06-30T09:00:00--2026-06-30T12:00:00`. -Обе границы должны быть в одном формате — смешанная точность отвергается. +Время в границах отбрасывается — окно всегда состоит из полных дней, так как +гранулярность Redmine — день. Границы можно смешивать (дата + datetime). Начало позже конца (`start > end`) — ошибка. -### `--commit` — автофиксация периода +### Авто-сдвиг начала периода -Флаг `--commit` сохраняет использованный период в YAML-конфиг, чтобы следующий запуск автоматически начинался с нового периода. +`--commit` и `--send` генерируют отчёт, сохраняют его в файл (по `--output` +или по шаблону `output.dir`/`output.filename`) и сдвигают начало окна. +`--send` дополнительно отправляет отчёт по email (см. +[ниже](#--send--отправка-отчёта-по-email)). -**Что делает:** +После успешного `--commit` (файл сохранён) или реальной отправки `--send` +(подтверждение «y» + успешная отправка) в конфиг пишется +`period.from = <конец окна отчёта>`. Следующий запуск без `--date` начинает +окно с граничного дня последнего отчёта. -1. Генерирует отчёт как обычно. -2. Сохраняет отчёт в файл: - - Если указан `--output` — по явному пути. - - Если `--output` не указан — по шаблону из `output.dir` / `output.filename`. -3. Записывает `period.last_used.from` / `period.last_used.to` в YAML-конфиг. -4. При `period.precision: datetime` сохраняет текущий момент времени (ISO с секундами). -5. При `period.precision: date` сохраняет даты периода. +Сдвиг НЕ происходит: -**Поведение `period.dynamic`:** +- если за период нет time entries (отчёт пуст — выход до сохранения); +- при отказе от подтверждения отправки в `--send` (ответ не «y»/«yes»); +- при ошибке SMTP в `--send` (соединение, аутентификация, таймаут); +- при ошибке сохранения файла отчёта. -- `dynamic: true` — следующий запуск (без `--date`) вычисляет период от `last_used`: - - Полный календарный месяц → следующий полный месяц. - - Произвольный диапазон → та же длительность, начиная со дня после `last_used.to`. -- `dynamic: false` — `--commit` перезаписывает `default_from`/`default_to` на использованный период. +Исключение: при явном `--commit` (сочетание `--send --commit`) отказ от +подтверждения отправки сдвиг не отменяет: окно всё равно сдвигается по +`--commit`. -**Нюанс:** при `--commit` YAML-файл перезаписывается целиком через `yaml.dump` — -пользовательские комментарии и ручное форматирование в файле теряются. +При сочетании `--send --commit` ошибка SMTP подавляет и сдвиг по `--commit`: +файл остаётся сохранён, но начало периода не двигается. -**Примеры:** +Примеры: ```bash -# Июнь 2026 → следующий запуск (без --date) → июль 2026 +# Окно 1--30 июня → следующий запуск (без --date) начнётся с 30 июня redmine-reporter --date 2026-06-01--2026-06-30 --commit -# Произвольный диапазон: 15-20 июня → следующий запуск → 21-26 июня +# Произвольный диапазон 15--20 июня → следующее окно с 20 июня redmine-reporter --date 2026-06-15--2026-06-20 --commit -# С datetime-точностью: повторный запуск не дублирует записи -redmine-reporter --commit +# Отправить и сдвинуть окно (только при подтверждении «y» и успехе) +redmine-reporter --date 2026-06-01--2026-06-30 --send --commit ``` +`--commit`/`--send` перезаписывают только `period.from`; остальные секции и +ключи YAML сохраняются. Файл перезаписывается через `yaml.dump` — +пользовательские комментарии и ручное форматирование теряются. + +### Миграция старых ключей периода + +Старые ключи периода больше не читаются и тихо игнорируются при загрузке — +их можно удалить из YAML: + +- `period.last_used` (включая `from`/`to`); +- `period.precision`; +- `period.dynamic`; +- `period.default_from` / `period.default_to`. + +Env `DEFAULT_FROM_DATE` / `DEFAULT_TO_DATE` заменены на `FROM_DATE`; конец +периода больше не настраивается (всегда сегодня). Дедупликация time entries +по `created_on`/`updated_on` удалена. + ### `email` — настройка отправки по почте Секция `email` используется флагом `--send`. Если секция не настроена или `smtp.host` @@ -266,8 +278,10 @@ email: ### `--send` — отправка отчёта по email -Флаг `--send` отправляет сгенерированный отчёт через SMTP сразу после сохранения -в файл. Требует настроенную секцию `email` в YAML-конфиге. +Флаг `--send` сохраняет отчёт в файл, отправляет его через SMTP и сдвигает +начало периода при подтверждении и успешной отправке (см. +[Авто-сдвиг](#авто-сдвиг-начала-периода)). Требует настроенную +секцию `email` в YAML-конфиге. **Что делает:** @@ -275,11 +289,14 @@ email: 2. Сохраняет отчёт в файл: - Если указан `--output` — по явному пути. - Если `--output` не указан — по шаблону из `output.dir` / `output.filename`. -3. Формирует MIME-письмо: +3. Запрашивает подтверждение: «Отправить? [y/N]». Ответ «y»/«yes» продолжает, + иначе отправка отменяется (без сдвига периода). +4. Формирует MIME-письмо: - Тема, plain-text тело и вложение (если `attach: true`). - При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта. -4. Отправляет через SMTP (таймаут 30 секунд). STARTTLS включается только при +5. Отправляет через SMTP (таймаут 30 секунд). STARTTLS включается только при `email.smtp.tls: true`; аутентификация — только если задан `email.smtp.user`. +6. При успешной отправке сдвигает начало периода. **Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся на диске, данные не теряются. @@ -302,7 +319,7 @@ redmine-reporter --date 2026-06-01--2026-06-30 --send # С явным путём redmine-reporter --date 2026-06-01--2026-06-30 --output ~/report.xlsx --send -# Отправить и зафиксировать период +# Отправить и сдвинуть начало периода redmine-reporter --date 2026-06-01--2026-06-30 --send --commit ``` @@ -312,7 +329,7 @@ redmine-reporter --date 2026-06-01--2026-06-30 --send --commit |---|---| | `--send` | Сохранить по шаблону → отправить | | `--send --output X` | Сохранить в X → отправить | -| `--send --commit` | Сохранить → отправить → зафиксировать период | +| `--send --commit` | Сохранить → подтвердить → отправить → сдвинуть начало периода | | `--send` с `email.html: true` | Письмо с plain-text + HTML-таблицей | | `--send` без `email` в конфиге | Ошибка, exit 1 | | `--send` при ошибке SMTP | Файл сохранён, ошибка в stderr, exit 1 | @@ -429,7 +446,7 @@ report: 2. `--output xlsx` (bare format) → путь формируется как `output.dir / output.filename` с подстановкой `{ext}` = bare format и дат из периода. 3. `--output /path/report` (без расширения) → дописывается `.output.default_format`. 4. `--output /path/report.csv` (с расширением) → используется как есть. -5. `--output /path/report.xyz` (неизвестное расширение) → используется как есть, форматтер выбирается по расширению. +5. `--output /path/report.xyz` (неизвестное расширение) → ошибка «Неизвестный формат файла», exit 1. ## Миграция с `.env` на YAML @@ -463,9 +480,8 @@ vim ~/.config/redmine-reporter/config.yml | `--init-config --force` | Перезаписать существующий YAML | | `--config-path PATH` | Путь к YAML-конфигу: загрузка при запуске, запись при `--init-config` и `--commit` (по умолчанию `~/.config/redmine-reporter/config.yml`) | -Если `DEFAULT_TO_DATE` не задана, а `DEFAULT_FROM_DATE` задана, сгенерированный -YAML будет содержать пустое `default_to`, и при запуске инструмент использует -сегодняшнюю дату. +Если задана `FROM_DATE`, сгенерированный YAML будет содержать `period.from` с +этим значением. ### Проверка после миграции @@ -507,14 +523,15 @@ rm ~/.config/redmine-reporter/config.yml ## `.env` (legacy) -Для обратной совместимости `.env` продолжает работать без изменений. +Для обратной совместимости `.env` продолжает работать как раньше, кроме ключей +периода: `DEFAULT_FROM_DATE` заменён на `FROM_DATE`, `DEFAULT_TO_DATE` удалён +(см. [Миграция старых ключей периода](#миграция-старых-ключей-периода)). ```ini REDMINE_URL=https://red.eltex.loc REDMINE_API_KEY=your-api-key REDMINE_AUTHOR=Кокос А.А. -DEFAULT_FROM_DATE=2026-06-01 -DEFAULT_TO_DATE=2026-06-30 +FROM_DATE=2026-06-01 ``` Если ни `.env`, ни YAML не заданы — используются кодовые дефолты (текущий месяц diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a86665d..55bad56 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -9,7 +9,7 @@ - [Экспорт в файл](#экспорт-в-файл) - [Содержимое отчёта](#содержимое-отчёта) - [Отправка по email](#отправка-по-email) -- [Ежемесячный цикл с --commit](#ежемесячный-цикл-с---commit) +- [Скользящее окно периода](#скользящее-окно-периода) - [Справочник флагов](#справочник-флагов) - [Устранение неполадок](#устранение-неполадок) - [См. также](#см-также) @@ -68,16 +68,15 @@ redmine-reporter --init-config --config-path /etc/redmine-reporter/config.yml ## Выбор периода -Источник периода выбирается по приоритету (от высшего к низшему): +Окно отчёта — диапазон дат `from..to`. Конец (`to`) всегда сегодняшняя дата, +если диапазон не задан явно через `--date`. -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-го числа по сегодня. +Приоритет начала окна (от высшего к низшему): + +1. `--date` — явный диапазон (полностью переопределяет окно). +2. `FROM_DATE` (env) — начало; конец — сегодня. +3. `period.from` (YAML) — начало; конец — сегодня. +4. Ничего не задано — текущий месяц: с 1-го числа по сегодня. Форматы `--date`: @@ -85,11 +84,18 @@ redmine-reporter --init-config --config-path /etc/redmine-reporter/config.yml # Даты redmine-reporter --date 2026-06-01--2026-06-30 -# Дата-время +# Дата-время (границы флорятся до полных дней) redmine-reporter --date 2026-06-01T09:00:00--2026-06-01T18:00:00 ``` -Смешанная точность (дата + дата-время в одном диапазоне) и диапазон, где начало позже конца, отвергаются с ошибкой. +Границы с временем округляются до дат — отчёт строится за полные дни диапазона. +Границы можно смешивать (дата + дата-время). Начало позже конца +(`start > end`) — ошибка. + +Краевые случаи: + +- `from` позже сегодняшней даты — окно «сегодня--сегодня». +- `from` некорректна (не `YYYY-MM-DD`) — warning + текущий месяц. ## Отчёт за другого пользователя @@ -206,6 +212,8 @@ redmine-reporter --send --commit Факты: - Файл отчёта сохраняется на диск ДО отправки: при ошибке SMTP файл остаётся на месте. +- Перед отправкой запрашивается подтверждение «Отправить? [y/N]»; ответ «y»/«yes» продолжает, иначе отправка отменяется (без сдвига периода, если не задан явный `--commit`). +- При успешной отправке начало периода сдвигается (см. [Скользящее окно периода](#скользящее-окно-периода)); при отмене (если не задан явный `--commit`) или ошибке SMTP — без сдвига. - Секция `email` отсутствует или `smtp.host` пуст → ошибка «Email не настроен. Добавьте секцию 'email' в конфиг...», exit 1. - `email.html: true` → письмо `multipart/alternative`: plain-text + HTML-таблица отчёта в теле. - Файл прикрепляется при `attach: true`; MIME-тип определяется по расширению, неизвестное → `application/octet-stream`. @@ -220,41 +228,42 @@ redmine-reporter --send --commit - «Ошибка отправки письма: ...» - «Не удалось подключиться к 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`. +После успешного `--commit` (файл сохранён) или реальной отправки `--send` +(подтверждение «y» + успех) окно отчёта сдвигается вперёд: в конфиг пишется +`period.from = <конец окна>`. Следующий запуск без `--date` +начинается с граничного дня последнего отчёта. Типичный цикл: ```bash -# 1. Отчёт за июнь 2026: сохранить файл, зафиксировать период +# 1. Отчёт за июнь 2026: сохранить, отправить, сдвинуть начало на 30 июня redmine-reporter --date 2026-06-01--2026-06-30 --send --commit -# 2. Следующий запуск без --date возьмёт июль 2026 автоматически -# (при dynamic: true и полном месяце) +# 2. Следующий запуск без --date берёт окно с 30 июня по сегодня redmine-reporter --send --commit ``` -Защита от дублей при `precision: datetime`: +Граничный день: начало следующего окна совпадает с днём коммита, поэтому этот +день перечитывается. Записи дня коммита, уже попавшие в прошлый отчёт, +повторятся (дедупликации по `created_on`/`updated_on` нет — это осознанное +следствие), зато поздние записи, добавленные в Redmine в этот день после +отправки, не теряются. -- Если в YAML есть `last_used.to` (cutoff), time entry сохраняется в отчёт, только если `created_on` И `updated_on` оба >= cutoff; если хотя бы одно поле раньше cutoff — запись исключается. -- Записи, у которых оба поля (`created_on`, `updated_on`) отсутствуют, сохраняются; если задано только одно поле, решает оно. -- Фильтр применяется всегда при `precision: datetime`, в том числе при явном `--date`. +Повторный запуск `--commit`/`--send` в тот же день даёт окно +«сегодня--сегодня», которое повторно включает уже отправленные записи этого +дня. -Важно: `--commit` перезаписывает YAML через `yaml.dump` — пользовательские комментарии в файле теряются. +Важно: `--commit`/`--send` перезаписывают только `period.from`; остальные +ключи YAML сохраняются, но пользовательские комментарии теряются +(перезапись через `yaml.dump`). ## Справочник флагов | Флаг | Описание | | --- | --- | -| `--date` | Диапазон периода: `YYYY-MM-DD--YYYY-MM-DD` или `YYYY-MM-DDTHH:MM:SS--YYYY-MM-DDTHH:MM:SS`. Смешанная точность и `start > end` отвергаются. | +| `--date` | Явный диапазон: границы — дата `YYYY-MM-DD` или datetime `YYYY-MM-DDTHH:MM:SS`. Время отбрасывается (полные дни), границы можно смешивать; `start > end` — ошибка. | | `--compact` | Компактный текстовый вывод в консоль вместо таблицы. | | `--output` | Путь к файлу (`.odt`/`.csv`/`.md`/`.html`/`.json`/`.xlsx`) или bare-формат — тогда путь берётся из YAML-шаблона; без флага — вывод в консоль (stdout). | | `--author` | Переопределить имя автора отчёта. | @@ -273,8 +282,8 @@ redmine-reporter --send --commit | `--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 после сохранения файла. | +| `--commit` | Сохранить отчёт в файл и сдвинуть начало периода (`period.from` = конец окна) в YAML. | +| `--send` | Отправить отчёт по email после сохранения файла; при подтверждении и успехе сдвинуть начало периода. | ## Устранение неполадок diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 4b486f4..4de9f70 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -3,7 +3,7 @@ import logging import os import re import sys -from datetime import datetime, timezone +from datetime import datetime from pathlib import Path from typing import List, Optional @@ -39,35 +39,38 @@ def _sanitize_error_text(text: str) -> str: return text +def _parse_boundary(value: str) -> datetime: + """Разбирает одну границу диапазона как дату или datetime без timezone.""" + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): + fmt = "%Y-%m-%d" + elif re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", value): + fmt = "%Y-%m-%dT%H:%M:%S" + else: + raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") + try: + return datetime.strptime(value, fmt) + except ValueError as e: + raise ValueError("Date range contains invalid calendar date") from e + + def parse_date_range(date_arg: str) -> tuple[str, str]: + """Возвращает границы диапазона в виде дат YYYY-MM-DD. + + Каждая граница может быть датой или datetime; время отбрасывается + (границы окна -- целые дни), поэтому смешение форматов допустимо. + """ if "--" not in date_arg: raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") parts = date_arg.split("--", 1) from_date, to_date = parts[0].strip(), parts[1].strip() - date_pattern = r"\d{4}-\d{2}-\d{2}" - datetime_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}" - if re.fullmatch(date_pattern, from_date) and re.fullmatch(date_pattern, to_date): - fmt = "%Y-%m-%d" - elif re.fullmatch(datetime_pattern, from_date) and re.fullmatch( - datetime_pattern, to_date - ): - fmt = "%Y-%m-%dT%H:%M:%S" - else: - raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") + start = _parse_boundary(from_date) + end = _parse_boundary(to_date) - try: - start = datetime.strptime(from_date, fmt) - end = datetime.strptime(to_date, fmt) - except ValueError as e: - raise ValueError("Date range contains invalid calendar date") from e - - if start > end: + if start.date() > end.date(): 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.date().isoformat(), end.date().isoformat() def _run_init_config(config_path: str, force: bool) -> int: @@ -99,10 +102,7 @@ def _run_init_config(config_path: str, force: bool) -> int: "verify_ssl": True, }, "period": { - "precision": "date", - "default_from": os.getenv("DEFAULT_FROM_DATE", "").strip(), - "default_to": os.getenv("DEFAULT_TO_DATE", "").strip(), - "dynamic": False, + "from": os.getenv("FROM_DATE", "").strip(), }, "output": { "dir": "", @@ -155,29 +155,6 @@ def _run_init_config(config_path: str, force: bool) -> int: return 0 -def _compute_dedup_cutoff() -> Optional[datetime]: - """Compute deduplication cutoff from config. - - If period.precision == 'datetime' and period.last_used.to is set, - return the datetime to filter out entries already accounted for. - Otherwise return None. - """ - if Config.get_period_precision() != "datetime": - return None - - last_to = Config.get_last_used_to() - if not last_to: - return None - - try: - dt = datetime.fromisoformat(last_to.replace("Z", "+00:00")) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt - except (ValueError, TypeError): - return None - - def _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool: """Возвращает финальное значение no_time. @@ -201,10 +178,11 @@ def _save_and_maybe_send( to_date: str, no_time: bool, do_send: bool, -) -> int: +) -> tuple[int, bool]: """Сохраняет отчёт в файл и опционально отправляет по email. - Returns 0 on success, 1 on error. + Returns (exit_code, sent): sent отражает факт реальной отправки письма, + чтобы main мог отличить отказ от подтверждения от успешной отправки. """ output_ext = os.path.splitext(output_arg)[1].lower() @@ -213,7 +191,7 @@ def _save_and_maybe_send( "❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx", file=sys.stderr, ) - return 1 + return 1, False formatter = get_formatter_by_extension( output_ext, @@ -236,7 +214,7 @@ def _save_and_maybe_send( f"Поддерживаются: {known_exts}", file=sys.stderr, ) - return 1 + return 1, False try: formatter.save(rows, output_arg) @@ -244,7 +222,7 @@ def _save_and_maybe_send( except Exception as e: fmt = output_ext.lstrip(".").upper() print(f"❌ {fmt} export error: {e}", file=sys.stderr) - return 1 + return 1, False if do_send: email_config = Config.get_email_config() @@ -254,7 +232,7 @@ def _save_and_maybe_send( "(~/.config/redmine-reporter/config.yml).", file=sys.stderr, ) - return 1 + return 1, False print("\n📧 Готово к отправке:") print(f" From: {email_config.from_}") @@ -267,7 +245,7 @@ def _save_and_maybe_send( response = input("Отправить? [y/N]: ").strip().lower() if response not in ("y", "yes"): print("⏭️ Отправка отменена.") - return 0 + return 0, False try: send_report( @@ -280,9 +258,10 @@ def _save_and_maybe_send( 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 1, False + return 0, True - return 0 + return 0, False def main(argv: Optional[List[str]] = None) -> int: @@ -363,12 +342,12 @@ def main(argv: Optional[List[str]] = None) -> int: parser.add_argument( "--commit", action="store_true", - help="Save used period as last_used in YAML config and auto-commit to file", + help="Save the report and advance period start to its end date in YAML config", ) parser.add_argument( "--send", action="store_true", - help="Send generated report via email after saving (requires email section in config)", + help="Send generated report via email after saving (requires email section in config); on success advance period start to its end date", ) args = parser.parse_args(argv) @@ -442,7 +421,6 @@ def main(argv: Optional[List[str]] = None) -> int: to_date, user_id=args.user_id or args.user_login or args.user_name, by_activity=args.by_activity, - dedup_before=_compute_dedup_cutoff(), ) except RedmineAPIError as e: print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr) @@ -485,6 +463,9 @@ def main(argv: Optional[List[str]] = None) -> int: activity = key.split(":", 1)[1] print(f" [{activity}]: {value}h", file=sys.stderr) + save_ok = False + report_sent = False + if args.output or args.commit: if args.output: output_arg = resolve_output_path( @@ -515,7 +496,7 @@ def main(argv: Optional[List[str]] = None) -> int: ) return 1 - ret = _save_and_maybe_send( + ret, report_sent = _save_and_maybe_send( rows, output_arg, author=Config.get_author(args.author), @@ -526,6 +507,7 @@ def main(argv: Optional[List[str]] = None) -> int: ) if ret != 0: return ret + save_ok = True elif args.send: # --send без --output и --commit: сохраняем по шаблону и отправляем @@ -546,7 +528,7 @@ def main(argv: Optional[List[str]] = None) -> int: ) return 1 - ret = _save_and_maybe_send( + ret, report_sent = _save_and_maybe_send( rows, output_arg, author=Config.get_author(args.author), @@ -557,6 +539,7 @@ def main(argv: Optional[List[str]] = None) -> int: ) if ret != 0: return ret + save_ok = True else: if args.compact: @@ -575,23 +558,17 @@ def main(argv: Optional[List[str]] = None) -> int: print(f"❌ Formatting error: {e}", file=sys.stderr) return 1 - if args.commit: - precision = Config.get_period_precision() - dynamic = Config._app.period_dynamic if Config._app else False - - if precision == "datetime": - # from -- фактическое начало окна отчёта; to -- дедуп-маркер (naive UTC). - now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") - from_str = from_date - to_str = now - else: - from_str = from_date - to_str = to_date + # Окно двигается ровно один раз за запуск: либо по явному --commit + # (операция завершилась кодом 0), либо по фактически отправленному письму --send. + should_advance = (args.commit and save_ok) or (args.send and report_sent) + if should_advance: + # Начало следующего окна совпадает с концом текущего: граничный день + # перечитывается. Дубли записей дня коммита осознанны, зато поздние + # записи, добавленные в тот же день после отправки, не теряются. + next_from = to_date try: - save_period_to_config( - args.config_path, from_str, to_str, precision, dynamic - ) + save_period_to_config(args.config_path, from_str=next_from) except Exception as e: print( f"❌ Не удалось сохранить период в конфиг: {e}", @@ -599,7 +576,7 @@ def main(argv: Optional[List[str]] = None) -> int: ) return 1 print( - f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}", + f"📌 Period committed [{next_from}] → {args.config_path}", file=sys.stderr, ) diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index 87a3d20..9fb4da2 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -1,5 +1,4 @@ import sys -from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple, Union import requests @@ -163,33 +162,6 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str: return str(activity) -def _parse_datetime(value: Any) -> Optional[datetime]: - """Parse a datetime from Redmine API response. - - Accepts datetime objects, ISO strings (with or without timezone), - or None. Returns a timezone-aware datetime or None. - Naive datetimes are treated as UTC (#58). - """ - if value is None: - return None - if isinstance(value, datetime): - return _ensure_aware_utc(value) - if isinstance(value, str): - try: - dt = datetime.fromisoformat(value.replace("Z", "+00:00")) - return _ensure_aware_utc(dt) - except (ValueError, TypeError): - return None - return None - - -def _ensure_aware_utc(dt: datetime) -> datetime: - """Возвращает aware datetime; naive трактуется как UTC (#58).""" - if dt.tzinfo is None: - return dt.replace(tzinfo=timezone.utc) - return dt - - def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]: """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" all_issues: List[Issue] = [] @@ -278,18 +250,12 @@ def fetch_issues_with_spent_time( to_date: str, user_id: Optional[Union[int, str]] = None, by_activity: bool = False, - dedup_before: Optional[datetime] = None, ) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]: """ Fetch unique issues linked to time entries of the given user in date range, along with total spent hours per issue. If user_id is None, uses current user. If by_activity is True, returns per-activity breakdown as third tuple element. - If dedup_before is set, entries with both fields set are kept only when - created_on AND updated_on are both >= dedup_before; an entry is excluded - if at least one of the fields is before the cutoff (dedup_before). - Entries with both fields missing (None) are kept; - if only one field is set, that field alone decides (>= cutoff keeps the entry). Returns list of (issue, total_hours, activities) tuples. Raises RedmineAPIError on API/auth/network failures. """ @@ -304,7 +270,9 @@ def fetch_issues_with_spent_time( activities_lookup = _load_time_entry_activities(redmine) if by_activity else {} time_entries = list( redmine.time_entry.filter( - user_id=target_user_id, from_date=from_date, to_date=to_date + user_id=target_user_id, + from_date=from_date, + to_date=to_date, ) ) except RedmineAPIError: @@ -312,38 +280,6 @@ def fetch_issues_with_spent_time( except Exception as exc: raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc - # Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте. - # Если оба поля заданы, запись сохраняется, только если created_on И updated_on - # оба >= dedup_before; если хотя бы одно из полей < dedup_before, - # запись исключается. - # Если оба поля None — запись сохраняется; если задано только одно поле, - # решает оно (>= dedup_before → запись сохраняется). - if dedup_before is not None: - # Нормализуем cutoff к aware UTC (#58): naive cutoff трактуем как UTC, - # чтобы сравнение с нормализованными created_on/updated_on было корректным. - dedup_before = _ensure_aware_utc(dedup_before) - filtered: list = [] - try: - for entry in time_entries: - created = _parse_datetime(getattr(entry, "created_on", None)) - updated = _parse_datetime(getattr(entry, "updated_on", None)) - - if created is None and updated is None: - filtered.append(entry) - elif created is not None and updated is not None: - if created >= dedup_before and updated >= dedup_before: - filtered.append(entry) - elif created is not None and created >= dedup_before: - filtered.append(entry) - elif updated is not None and updated >= dedup_before: - filtered.append(entry) - except TypeError as exc: - raise RedmineAPIError( - f"Failed to compare time entry dates with deduplication cutoff: {exc}", - original=exc, - ) from exc - time_entries = filtered - # Агрегируем часы по issue.id (и активности, если требуется) spent_time: Dict[int, float] = {} spent_by_activity: Dict[int, Dict[str, float]] = {} diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 105a17c..2186728 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -2,7 +2,7 @@ import logging import os import sys from dataclasses import dataclass, field -from datetime import date, datetime, timedelta, timezone +from datetime import date from pathlib import Path from typing import Dict, Union @@ -40,24 +40,13 @@ class EmailConfig: html: bool = False -@dataclass -class PeriodLastUsed: - from_: str = "" - to: str = "" - - @dataclass class AppConfig: redmine_url: str = "" redmine_api_key: str = "" redmine_author: str = "" redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY - period_precision: str = "date" - period_default_from: str = "" - period_default_to: str = "" - period_dynamic: bool = False - period_last_used_from: str = "" - period_last_used_to: str = "" + period_from: str = "" output_dir: str = "" output_filename: str = "{author}_{from}_{to}.{ext}" output_default_format: str = "xlsx" @@ -100,12 +89,7 @@ class AppConfig: redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"), redmine_author=cls._resolve_str(raw, "redmine", "author"), redmine_verify=cls._resolve_verify(raw), - period_precision=cls._resolve_str(raw, "period", "precision") or "date", - period_default_from=cls._resolve_str(raw, "period", "default_from"), - period_default_to=cls._resolve_str(raw, "period", "default_to"), - period_dynamic=cls._resolve_bool(raw, "period", "dynamic"), - period_last_used_from=cls._resolve_str(raw, "period", "last_used", "from"), - period_last_used_to=cls._resolve_str(raw, "period", "last_used", "to"), + period_from=cls._resolve_str(raw, "period", "from"), output_dir=cls._resolve_str(raw, "output", "dir"), output_filename=cls._resolve_str(raw, "output", "filename") or "{author}_{from}_{to}.{ext}", @@ -118,17 +102,6 @@ class AppConfig: email=cls._resolve_email(raw), ) - @classmethod - def from_env(cls) -> "AppConfig": - """Загружает настройки из переменных окружения.""" - return cls( - redmine_url=os.getenv("REDMINE_URL", "").strip().rstrip("/"), - redmine_api_key=os.getenv("REDMINE_API_KEY", "").strip(), - redmine_author=os.getenv("REDMINE_AUTHOR", "").strip(), - period_default_from=os.getenv("DEFAULT_FROM_DATE", "").strip(), - period_default_to=os.getenv("DEFAULT_TO_DATE", "").strip(), - ) - @staticmethod def _resolve_str(raw: dict, section: str, *keys: str) -> str: value = raw.get(section) @@ -365,24 +338,6 @@ class Config: return cls._app.redmine_author return "" - @classmethod - def get_period_precision(cls) -> str: - if cls._app: - return cls._app.period_precision or "date" - return "date" - - @classmethod - def get_last_used_from(cls) -> str: - if cls._app: - return cls._app.period_last_used_from - return "" - - @classmethod - def get_last_used_to(cls) -> str: - if cls._app: - return cls._app.period_last_used_to - return "" - @classmethod def get_output_dir(cls) -> str: if cls._app: @@ -432,50 +387,35 @@ class Config: @classmethod def get_default_date_range(cls) -> str: - from_env = os.getenv("DEFAULT_FROM_DATE", "").strip() - to_env = os.getenv("DEFAULT_TO_DATE", "").strip() + """Окно отчёта по умолчанию: from..сегодня. + + Приоритет начала: env FROM_DATE -> YAML period.from -> 1-е число + текущего месяца. Некорректная дата не роняет CLI -- логируется + warning и берётся текущий месяц. + """ today = date.today() - today_str = today.isoformat() + raw_from = os.getenv("FROM_DATE", "").strip() + if not raw_from and cls._app: + raw_from = cls._app.period_from - if from_env: - return f"{from_env}--{to_env or today_str}" + if raw_from: + try: + start = date.fromisoformat(raw_from) + except (ValueError, TypeError): + logger.warning( + "Invalid FROM_DATE/period.from %r -- expected ISO date " + "(YYYY-MM-DD); using current month", + raw_from, + ) + start = today.replace(day=1) + else: + start = today.replace(day=1) - if ( - cls._app - and cls._app.period_dynamic - and (cls._app.period_precision or "date") == "datetime" - and cls._app.period_last_used_to - ): - last_to = datetime.fromisoformat( - cls._app.period_last_used_to.replace("Z", "+00:00") - ) - if last_to.tzinfo is None: - last_to = last_to.replace(tzinfo=timezone.utc) - else: - last_to = last_to.astimezone(timezone.utc) - now_utc = datetime.now(timezone.utc) - fmt = "%Y-%m-%dT%H:%M:%S" - return f"{last_to.strftime(fmt)}--{now_utc.strftime(fmt)}" - - if ( - cls._app - and cls._app.period_dynamic - and cls._app.period_last_used_from - and cls._app.period_last_used_to - ): - nf, nt = compute_next_period( - cls._app.period_last_used_from, - cls._app.period_last_used_to, - cls._app.period_precision or "date", - ) - return f"{nf}--{nt}" - - if cls._app and cls._app.period_default_from: - default_to = cls._app.period_default_to or today_str - return f"{cls._app.period_default_from}--{default_to}" - - start = today.replace(day=1) - return f"{start.isoformat()}--{today_str}" + # Будущая start инвертирует диапазон и ломает фильтр Redmine, + # поэтому сжимаем окно до сегодняшнего дня. + if start > today: + start = today + return f"{start.isoformat()}--{today.isoformat()}" @classmethod def validate(cls) -> None: @@ -500,52 +440,3 @@ class Config: raise ValueError( "REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD" ) - - -def compute_next_period( - last_from: str, last_to: str, precision: str -) -> tuple[str, str]: - """Compute the next report period based on the last committed period. - - - For a full calendar month → next full calendar month. - - For an arbitrary range → same duration, starting the day after last_to. - - For datetime precision → same duration, starting 1 second after last_to. - """ - from datetime import datetime as dt_mod - - if precision == "datetime": - fmt = "%Y-%m-%dT%H:%M:%S" - from_dt = dt_mod.fromisoformat(last_from.replace("Z", "+00:00")) - to_dt = dt_mod.fromisoformat(last_to.replace("Z", "+00:00")) - duration = to_dt - from_dt - next_from_dt = to_dt + timedelta(seconds=1) - next_to_dt = next_from_dt + duration - return next_from_dt.strftime(fmt), next_to_dt.strftime(fmt) - - from_parts = last_from.split("-") - to_parts = last_to.split("-") - from_d = date(int(from_parts[0]), int(from_parts[1]), int(from_parts[2])) - to_d = date(int(to_parts[0]), int(to_parts[1]), int(to_parts[2])) - - first_of_month = from_d.replace(day=1) - if from_d == first_of_month: - if to_d.month == 12: - last_of_month = date(to_d.year, 12, 31) - else: - next_first = date(to_d.year, to_d.month + 1, 1) - last_of_month = next_first - timedelta(days=1) - if to_d == last_of_month: - if to_d.month == 12: - nstart = date(to_d.year + 1, 1, 1) - else: - nstart = date(to_d.year, to_d.month + 1, 1) - if nstart.month == 12: - nend = date(nstart.year, 12, 31) - else: - nend = date(nstart.year, nstart.month + 1, 1) - timedelta(days=1) - return nstart.isoformat(), nend.isoformat() - - duration = to_d - from_d - next_from = to_d + timedelta(days=1) - next_to = next_from + duration - return next_from.isoformat(), next_to.isoformat() diff --git a/redmine_reporter/yaml_config.py b/redmine_reporter/yaml_config.py index c38dd96..f5b9d89 100644 --- a/redmine_reporter/yaml_config.py +++ b/redmine_reporter/yaml_config.py @@ -153,19 +153,11 @@ def resolve_output_path( return arg -def save_period_to_config( - config_path: str, - from_str: str, - to_str: str, - precision: str, - dynamic: bool, -) -> None: - """Save committed period to YAML config file. +def save_period_to_config(config_path: str, from_str: str) -> None: + """Save committed period start to YAML config file. - Writes period.last_used.from / period.last_used.to. - When dynamic=False, also overwrites period.default_from / period.default_to. - - Preserves all other sections unchanged. Creates config file if missing. + Writes period.from only: all other sections and keys stay untouched. + Creates the config file (and its parent directory) if missing. """ import yaml @@ -176,11 +168,7 @@ def save_period_to_config( raw = yaml.safe_load(fh) or {} period = raw.setdefault("period", {}) - period["last_used"] = {"from": from_str, "to": to_str} - - if not dynamic: - period["default_from"] = from_str.split("T")[0] if "T" in from_str else from_str - period["default_to"] = to_str.split("T")[0] if "T" in to_str else to_str + period["from"] = from_str ensure_config_dir(path.parent) with open(path, "w", encoding="utf-8") as fh: diff --git a/tests/test_cli.py b/tests/test_cli.py index 41db9cc..ebd9f7f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,10 +30,14 @@ def _reset_config_overrides(): [ ("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")), (" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")), + # datetime-границы флорятся до даты -- окно состоит из целых дней. ( "2026-06-01T00:00:00--2026-06-30T23:59:59", - ("2026-06-01T00:00:00", "2026-06-30T23:59:59"), + ("2026-06-01", "2026-06-30"), ), + # Смешение форматов между границами допустимо. + ("2026-06-01--2026-06-30T23:59:59", ("2026-06-01", "2026-06-30")), + ("2026-06-01T09:30:00--2026-06-30", ("2026-06-01", "2026-06-30")), ], ) def test_parse_date_range_valid(date_arg, expected): @@ -48,7 +52,8 @@ def test_parse_date_range_valid(date_arg, expected): "2026-02-30--2026-03-01", "2026-02-01--2026-01-31", "2026-06-01T25:00:00--2026-06-30T23:59:59", - "2026-06-01--2026-06-30T23:59:59", + "2026-06-01 00:00:00--2026-06-30", + "2026-06-01T00:00:00+03:00--2026-06-30T23:59:59", "2026-06-30T23:59:59--2026-06-01T00:00:00", ], ) @@ -438,8 +443,7 @@ class TestInitConfig: "REDMINE_URL=https://redmine.example.com\n" "REDMINE_API_KEY=secret-from-dotenv\n" "REDMINE_AUTHOR=ivan.ivanov\n" - "DEFAULT_FROM_DATE=2026-01-01\n" - "DEFAULT_TO_DATE=2026-01-31\n" + "FROM_DATE=2026-01-01\n" "SMTP_PASSWORD=smtp-secret\n" ) monkeypatch.chdir(tmp_path) @@ -448,8 +452,7 @@ class TestInitConfig: "REDMINE_URL", "REDMINE_API_KEY", "REDMINE_AUTHOR", - "DEFAULT_FROM_DATE", - "DEFAULT_TO_DATE", + "FROM_DATE", "SMTP_PASSWORD", ): monkeypatch.delenv(var, raising=False) @@ -464,8 +467,7 @@ class TestInitConfig: assert data["redmine"]["url"] == "https://redmine.example.com" assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}" assert data["redmine"]["author"] == "ivan.ivanov" - assert data["period"]["default_from"] == "2026-01-01" - assert data["period"]["default_to"] == "2026-01-31" + assert data["period"]["from"] == "2026-01-01" assert data["email"]["smtp"]["password"] == "${SMTP_PASSWORD}" @mock.patch.dict(os.environ, VALID_ENV, clear=True) @@ -678,16 +680,14 @@ class TestCommitFlag: @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.save_period_to_config") def test_commit_saves_period_to_config(self, mock_save, mock_fetch, tmp_path): - """--commit сохраняет период в YAML-конфиг.""" + """--commit пишет в period.from конец окна отчёта (день коммита).""" import yaml issue = _MockIssue() mock_fetch.return_value = [(issue, 1.0, None)] config_path = tmp_path / "config.yml" - config_path.write_text( - yaml.dump({"period": {"precision": "date", "dynamic": True}}) - ) + config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}})) with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: mock_formatter = mock.MagicMock() @@ -705,36 +705,29 @@ class TestCommitFlag: ) assert code == 0 - mock_save.assert_called_once_with( - str(config_path), "2026-06-01", "2026-06-30", "date", True - ) + mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30") @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.save_period_to_config") - def test_commit_with_precision_datetime_passes_datetime_strings( + def test_commit_period_from_equals_window_end( self, mock_save, mock_fetch, tmp_path ): - """При precision=datetime --commit сохраняет from окна и to в naive UTC.""" - from datetime import datetime - + """Конец окна 2026-09-30 -- period.from ровно 2026-09-30, не 2026-10-01.""" import yaml issue = _MockIssue() mock_fetch.return_value = [(issue, 1.0, None)] config_path = tmp_path / "config.yml" - config_path.write_text( - yaml.dump({"period": {"precision": "datetime", "dynamic": True}}) - ) + config_path.write_text(yaml.dump({"period": {"from": "2026-09-01"}})) with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: - mock_formatter = mock.MagicMock() - mock_get.return_value = mock_formatter + mock_get.return_value = mock.MagicMock() code = main( [ "--date", - "2026-09-10T09:00:00--2026-09-12T18:00:00", + "2026-09-01--2026-09-30", "--commit", "--output", str(tmp_path / "report.xlsx"), @@ -744,40 +737,29 @@ class TestCommitFlag: ) assert code == 0 - call_args = mock_save.call_args - assert call_args is not None - saved_from, saved_to = call_args.args[1], call_args.args[2] - assert saved_from == "2026-09-10T09:00:00" - assert datetime.strptime(saved_to, "%Y-%m-%dT%H:%M:%S") - assert call_args.args[3] == "datetime" - assert call_args.args[4] is True + mock_save.assert_called_once_with(str(config_path), from_str="2026-09-30") @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.save_period_to_config") - def test_commit_with_precision_datetime_saves_naive_utc_to( + def test_commit_period_from_equals_window_end_year_boundary( self, mock_save, mock_fetch, tmp_path ): - """При precision=datetime --commit сохраняет to как naive UTC (#70).""" - from datetime import datetime - + """Окно до 2026-12-31 -- from_str равен 2026-12-31: timedelta-математики нет.""" import yaml issue = _MockIssue() mock_fetch.return_value = [(issue, 1.0, None)] config_path = tmp_path / "config.yml" - config_path.write_text( - yaml.dump({"period": {"precision": "datetime", "dynamic": True}}) - ) + config_path.write_text(yaml.dump({"period": {"from": "2026-12-01"}})) with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: - mock_formatter = mock.MagicMock() - mock_get.return_value = mock_formatter + mock_get.return_value = mock.MagicMock() code = main( [ "--date", - "2026-09-10T09:00:00--2026-09-12T18:00:00", + "2026-12-01--2026-12-31", "--commit", "--output", str(tmp_path / "report.xlsx"), @@ -787,120 +769,7 @@ class TestCommitFlag: ) assert code == 0 - call_args = mock_save.call_args - assert call_args is not None - saved_from, saved_to = call_args.args[1], call_args.args[2] - assert saved_from == "2026-09-10T09:00:00" - parsed = datetime.fromisoformat(saved_to) - assert parsed.tzinfo is None, f"{saved_to} must be naive UTC" - - @mock.patch.dict(os.environ, VALID_ENV, clear=True) - @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") - @mock.patch("redmine_reporter.cli.save_period_to_config") - def test_commit_datetime_persists_window_start_and_now( - self, mock_save, mock_fetch, tmp_path - ): - """--commit при datetime сохраняет начало окна и текущий naive UTC (#70).""" - from datetime import datetime as real_datetime - - import yaml - - issue = _MockIssue() - mock_fetch.return_value = [(issue, 1.0, None)] - - config_path = tmp_path / "config.yml" - config_path.write_text( - yaml.dump({"period": {"precision": "datetime", "dynamic": True}}) - ) - - fake_datetime = mock.MagicMock() - fake_datetime.fromisoformat = real_datetime.fromisoformat - fake_datetime.strptime = real_datetime.strptime - fake_datetime.now.return_value = real_datetime(2026, 9, 15, 12, 0, 0) - - with mock.patch("redmine_reporter.cli.datetime", fake_datetime): - with mock.patch( - "redmine_reporter.cli.get_formatter_by_extension" - ) as mock_get: - mock_formatter = mock.MagicMock() - mock_get.return_value = mock_formatter - code = main( - [ - "--commit", - "--date", - "2026-09-10T09:00:00--2026-09-12T18:00:00", - "--output", - str(tmp_path / "report.xlsx"), - "--config-path", - str(config_path), - ] - ) - assert code == 0 - - mock_save.assert_called_once_with( - str(config_path), - "2026-09-10T09:00:00", - "2026-09-15T12:00:00", - "datetime", - True, - ) - - @mock.patch.dict(os.environ, VALID_ENV, clear=True) - @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") - def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path): - """Без записей --commit не пишет конфиг.""" - mock_fetch.return_value = None - - config_path = tmp_path / "config.yml" - config_path.write_text("period:\n precision: date\n dynamic: true\n") - - code = main( - [ - "--date", - "2026-01-01--2026-01-31", - "--commit", - "--config-path", - str(config_path), - ] - ) - assert code == 0 - - @mock.patch.dict(os.environ, VALID_ENV, clear=True) - @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") - @mock.patch("redmine_reporter.cli.save_period_to_config") - def test_commit_dynamic_false_overwrites_defaults( - self, mock_save, mock_fetch, tmp_path - ): - """При dynamic=false --commit перезаписывает default_from/to.""" - import yaml - - issue = _MockIssue() - mock_fetch.return_value = [(issue, 1.0, None)] - - config_path = tmp_path / "config.yml" - config_path.write_text( - yaml.dump({"period": {"precision": "date", "dynamic": False}}) - ) - - with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: - mock_formatter = mock.MagicMock() - mock_get.return_value = mock_formatter - code = main( - [ - "--date", - "2026-06-01--2026-06-30", - "--commit", - "--output", - str(tmp_path / "report.xlsx"), - "--config-path", - str(config_path), - ] - ) - assert code == 0 - - mock_save.assert_called_once_with( - str(config_path), "2026-06-01", "2026-06-30", "date", False - ) + mock_save.assert_called_once_with(str(config_path), from_str="2026-12-31") @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @@ -918,7 +787,7 @@ class TestCommitFlag: config_path.write_text( yaml.dump( { - "period": {"precision": "date", "dynamic": True}, + "period": {"from": "2026-06-01"}, "output": { "dir": str(tmp_path / "reports"), "filename": "report_{date}.{ext}", @@ -956,9 +825,7 @@ class TestCommitFlag: mock_fetch.return_value = [(issue, 1.0, None)] config_path = tmp_path / "config.yml" - config_path.write_text( - yaml.dump({"period": {"precision": "date", "dynamic": True}}) - ) + config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}})) with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: mock_formatter = mock.MagicMock() @@ -982,16 +849,12 @@ class TestCommitFlag: @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.save_period_to_config") - def test_commit_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path): - """Пустой список задач — --commit не сохраняет конфиг.""" - import yaml - - mock_fetch.return_value = [] + def test_commit_no_entries_does_not_save(self, mock_save, mock_fetch, tmp_path): + """Без записей --commit не пишет конфиг.""" + mock_fetch.return_value = None config_path = tmp_path / "config.yml" - config_path.write_text( - yaml.dump({"period": {"precision": "date", "dynamic": True}}) - ) + config_path.write_text("period:\n from: '2026-01-01'\n") code = main( [ @@ -1005,6 +868,64 @@ class TestCommitFlag: assert code == 0 mock_save.assert_not_called() + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path): + """Пустой список задач — --commit не сохраняет конфиг.""" + import yaml + + mock_fetch.return_value = [] + + config_path = tmp_path / "config.yml" + config_path.write_text(yaml.dump({"period": {"from": "2026-01-01"}})) + + code = main( + [ + "--date", + "2026-01-01--2026-01-31", + "--commit", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + mock_save.assert_not_called() + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_save_error_does_not_advance_period( + self, mock_save, mock_fetch, tmp_path + ): + """Сбой записи отчёта не двигает окно: --commit фиксирует только успешно сохранённый отчёт.""" + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + + config_path = tmp_path / "config.yml" + config_path.write_text(yaml.dump({"period": {"from": "2026-06-01"}})) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_formatter.save.side_effect = OSError("disk full") + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--commit", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + + assert code == 1 + mock_save.assert_not_called() + # --------------------------------------------------------------------------- # #45: --send tests @@ -1203,8 +1124,7 @@ class TestSendFlag: " to:\n" " - boss@example.com\n" "period:\n" - " precision: date\n" - " dynamic: true\n" + " from: '2026-06-01'\n" ) code = main( @@ -1221,7 +1141,7 @@ class TestSendFlag: ) assert code == 0 mock_send.assert_called_once() - mock_save.assert_called_once() + mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30") @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @@ -1231,6 +1151,192 @@ class TestSendFlag: code = main(["--date", "2026-06-01--2026-06-30", "--send"]) assert code == 0 + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.send_report") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("redmine_reporter.cli.save_period_to_config") + @mock.patch("builtins.input") + def test_send_success_advances_period( + self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path + ): + """Успешная отправка сдвигает period.from на конец окна отчёта.""" + mock_input.return_value = "y" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + mock_get.return_value = mock.MagicMock() + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + ) + + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--output", + str(tmp_path / "report.xlsx"), + "--send", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30") + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.send_report") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("redmine_reporter.cli.save_period_to_config") + @mock.patch("builtins.input") + def test_send_declined_does_not_advance_period( + self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path + ): + """Отказ от подтверждения не сдвигает окно.""" + mock_input.return_value = "n" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + mock_get.return_value = mock.MagicMock() + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + ) + + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--output", + str(tmp_path / "report.xlsx"), + "--send", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + mock_send.assert_not_called() + mock_save.assert_not_called() + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.send_report") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("redmine_reporter.cli.save_period_to_config") + @mock.patch("builtins.input") + def test_send_smtp_error_does_not_advance_period( + self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path + ): + """Ошибка SMTP не сдвигает окно.""" + from redmine_reporter.client import RedmineAPIError + + mock_input.return_value = "y" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + mock_get.return_value = mock.MagicMock() + mock_send.side_effect = RedmineAPIError("SMTP connection failed") + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + ) + + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--output", + str(tmp_path / "report.xlsx"), + "--send", + "--config-path", + str(config_path), + ] + ) + assert code == 1 + mock_save.assert_not_called() + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.send_report") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("redmine_reporter.cli.save_period_to_config") + @mock.patch("builtins.input") + def test_send_commit_declined_send_still_advances( + self, mock_input, mock_save, mock_get, mock_send, mock_fetch, tmp_path + ): + """--send --commit: отказ от отправки не мешает явному --commit сдвинуть окно.""" + mock_input.return_value = "n" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + mock_get.return_value = mock.MagicMock() + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + "period:\n" + " from: '2026-06-01'\n" + ) + + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--output", + str(tmp_path / "report.xlsx"), + "--send", + "--commit", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + mock_send.assert_not_called() + mock_save.assert_called_once_with(str(config_path), from_str="2026-06-30") + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_send_commit_no_entries_does_not_save(self, mock_save, mock_fetch): + """--send --commit без записей не трогает конфиг.""" + mock_fetch.return_value = None + code = main(["--date", "2026-06-01--2026-06-30", "--send", "--commit"]) + assert code == 0 + mock_save.assert_not_called() + class TestSendHtmlBody: """Tests for email.html body generation.""" @@ -1385,7 +1491,7 @@ class TestReportNoTimeIntegration: { "redmine": {"url": "https://x.com", "api_key": "token"}, "report": {"no_time": True}, - "period": {"precision": "date", "dynamic": True}, + "period": {"from": "2026-06-01"}, } ) ) @@ -1425,7 +1531,7 @@ class TestReportNoTimeIntegration: { "redmine": {"url": "https://x.com", "api_key": "token"}, "report": {"no_time": False}, - "period": {"precision": "date", "dynamic": True}, + "period": {"from": "2026-06-01"}, } ) ) @@ -1465,7 +1571,7 @@ class TestReportNoTimeIntegration: { "redmine": {"url": "https://x.com", "api_key": "token"}, "report": {"no_time": False}, - "period": {"precision": "date", "dynamic": True}, + "period": {"from": "2026-06-01"}, } ) ) @@ -1581,45 +1687,6 @@ class TestReportNoTimeIntegration: assert kwargs.get("no_time") is True -# --------------------------------------------------------------------------- -# #59: dynamic + precision=datetime без --date -# --------------------------------------------------------------------------- - - -@mock.patch.dict(os.environ, VALID_ENV, clear=True) -@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") -def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path): - """dynamic+datetime без --date: скользящее окно от last_used.to до now (#70). - - Ожидаемое поведение после фикса #70: дефолтный диапазон начинается с - last_used.to и заканчивается текущим моментом, а не сдвинутым периодом. - """ - issue = _MockIssue() - mock_fetch.return_value = [(issue, 1.0, None)] - - config_path = tmp_path / "config.yml" - config_path.write_text( - "period:\n" - " precision: datetime\n" - " dynamic: true\n" - " last_used:\n" - " from: '2026-06-01T00:00:00'\n" - " to: '2026-06-30T23:59:59'\n" - ) - - real_dt = __import__("datetime").datetime - with mock.patch("redmine_reporter.config.datetime") as dt: - dt.fromisoformat = real_dt.fromisoformat - dt.now.return_value = real_dt(2026, 7, 15, 10, 0, 0) - code = main(["--config-path", str(config_path)]) - - assert code == 0 - args, _ = mock_fetch.call_args - from_date, to_date = args[0], args[1] - assert from_date == "2026-06-30T23:59:59" - assert to_date == "2026-07-15T10:00:00" - - # --------------------------------------------------------------------------- # #55: sanitize Redmine exception output in CLI # --------------------------------------------------------------------------- diff --git a/tests/test_client.py b/tests/test_client.py index 19ab5ad..e8566f0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -605,256 +605,6 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class): assert len(result) == 250 -# -- #47: Дедупликация time entries по datetime -- - - -@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) -@mock.patch("redmine_reporter.client.Redmine") -def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class): - """Записи с created_on/updated_on раньше dedup_before исключаются.""" - from datetime import datetime, timezone - - mock_redmine = mock_redmine_class.return_value - _configure_current_user(mock_redmine, user_id=123) - - dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) - - e1 = mock.MagicMock() - e1.issue.id = 1 - e1.hours = 2.0 - e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc) - e1.updated_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc) - - e2 = mock.MagicMock() - e2.issue.id = 2 - e2.hours = 1.0 - e2.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc) - e2.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc) - - mock_redmine.time_entry.filter.return_value = [e1, e2] - - mock_issue1 = mock.MagicMock() - mock_issue1.id = 1 - mock_issue1.project = "P" - mock_issue2 = mock.MagicMock() - mock_issue2.id = 2 - mock_issue2.project = "P" - mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2] - - result = fetch_issues_with_spent_time( - "2026-07-01", "2026-07-01", dedup_before=dedup_cutoff - ) - - assert result is not None - assert len(result) == 1 - assert result[0][0].id == 2 - - -@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) -@mock.patch("redmine_reporter.client.Redmine") -def test_dedup_filters_entries_with_old_created_even_if_updated_recently( - mock_redmine_class, -): - """Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика).""" - from datetime import datetime, timezone - - mock_redmine = mock_redmine_class.return_value - _configure_current_user(mock_redmine, user_id=123) - - dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) - - e1 = mock.MagicMock() - e1.issue.id = 1 - e1.hours = 2.0 - e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc) - e1.updated_on = datetime(2026, 7, 1, 15, 0, 0, tzinfo=timezone.utc) - - mock_redmine.time_entry.filter.return_value = [e1] - mock_redmine.issue.filter.return_value = [] - - result = fetch_issues_with_spent_time( - "2026-07-01", "2026-07-01", dedup_before=dedup_cutoff - ) - - assert result is None - - -@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) -@mock.patch("redmine_reporter.client.Redmine") -def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class): - """Записи с created_on/updated_on >= dedup_before сохраняются.""" - from datetime import datetime, timezone - - mock_redmine = mock_redmine_class.return_value - _configure_current_user(mock_redmine, user_id=123) - - dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) - - e1 = mock.MagicMock() - e1.issue.id = 1 - e1.hours = 2.0 - e1.created_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) - e1.updated_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) - - mock_redmine.time_entry.filter.return_value = [e1] - - mock_issue1 = mock.MagicMock() - mock_issue1.id = 1 - mock_issue1.project = "P" - mock_issue1.subject = "T" - mock_issue1.status = "New" - mock_redmine.issue.filter.return_value = [mock_issue1] - - result = fetch_issues_with_spent_time( - "2026-07-01", "2026-07-01", dedup_before=dedup_cutoff - ) - - assert result is not None - assert len(result) == 1 - - -@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) -@mock.patch("redmine_reporter.client.Redmine") -def test_dedup_entries_without_created_on_are_kept(mock_redmine_class): - """Записи без created_on/updated_on не фильтруются (сохраняются).""" - from datetime import datetime, timezone - - mock_redmine = mock_redmine_class.return_value - _configure_current_user(mock_redmine, user_id=123) - - dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) - - e1 = mock.MagicMock() - e1.issue.id = 1 - e1.hours = 2.0 - e1.created_on = None - e1.updated_on = None - - mock_redmine.time_entry.filter.return_value = [e1] - - mock_issue1 = mock.MagicMock() - mock_issue1.id = 1 - mock_issue1.project = "P" - mock_issue1.subject = "T" - mock_issue1.status = "New" - mock_redmine.issue.filter.return_value = [mock_issue1] - - result = fetch_issues_with_spent_time( - "2026-07-01", "2026-07-01", dedup_before=dedup_cutoff - ) - - assert result is not None - assert len(result) == 1 - - -@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) -@mock.patch("redmine_reporter.client.Redmine") -def test_dedup_handles_string_created_on(mock_redmine_class): - """created_on может быть строкой ISO — парсим корректно.""" - from datetime import datetime, timezone - - mock_redmine = mock_redmine_class.return_value - _configure_current_user(mock_redmine, user_id=123) - - dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) - - e1 = mock.MagicMock() - e1.issue.id = 1 - e1.hours = 2.0 - e1.created_on = "2026-07-01T10:00:00Z" - e1.updated_on = "2026-07-01T10:00:00Z" - - mock_redmine.time_entry.filter.return_value = [e1] - mock_redmine.issue.filter.return_value = [] - - result = fetch_issues_with_spent_time( - "2026-07-01", "2026-07-01", dedup_before=dedup_cutoff - ) - - assert result is None - - -@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) -@mock.patch("redmine_reporter.client.Redmine") -def test_no_dedup_when_cutoff_is_none(mock_redmine_class): - """Без dedup_before фильтрация не применяется.""" - mock_redmine = mock_redmine_class.return_value - _configure_current_user(mock_redmine, user_id=123) - - e1 = mock.MagicMock() - e1.issue.id = 1 - e1.hours = 2.0 - e1.created_on = None - e1.updated_on = None - - mock_redmine.time_entry.filter.return_value = [e1] - - mock_issue1 = mock.MagicMock() - mock_issue1.id = 1 - mock_issue1.project = "P" - mock_issue1.subject = "T" - mock_issue1.status = "New" - mock_redmine.issue.filter.return_value = [mock_issue1] - - result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=None) - - assert result is not None - assert len(result) == 1 - - -@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) -@mock.patch("redmine_reporter.client.Redmine") -def test_dedup_mixed_entries_correct_filtering(mock_redmine_class): - """Смешанные записи: старые исключаются, новые сохраняются, аки идут.""" - from datetime import datetime, timezone - - mock_redmine = mock_redmine_class.return_value - _configure_current_user(mock_redmine, user_id=123) - - dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) - - e_old = mock.MagicMock() - e_old.issue.id = 10 - e_old.hours = 1.0 - e_old.created_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc) - e_old.updated_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc) - - e_new = mock.MagicMock() - e_new.issue.id = 20 - e_new.hours = 3.0 - e_new.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc) - e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc) - - e_akiy = mock.MagicMock() - e_akiy.issue.id = 30 - e_akiy.hours = 5.0 - e_akiy.created_on = None - e_akiy.updated_on = None - - mock_redmine.time_entry.filter.return_value = [e_old, e_new, e_akiy] - - mock_issue_new = mock.MagicMock() - mock_issue_new.id = 20 - mock_issue_new.project = "P" - mock_issue_new.subject = "T" - mock_issue_new.status = "New" - mock_issue_akiy = mock.MagicMock() - mock_issue_akiy.id = 30 - mock_issue_akiy.project = "P" - mock_issue_akiy.subject = "T2" - mock_issue_akiy.status = "New" - mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy] - - result = fetch_issues_with_spent_time( - "2026-07-01", "2026-07-01", dedup_before=dedup_cutoff - ) - - assert result is not None - assert len(result) == 2 - ids = {r[0].id for r in result} - assert ids == {20, 30} - - # -- Пагинация time entries (>100 записей) -- @@ -904,149 +654,6 @@ def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class): 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: предупреждения о молчаливой потере данных -- @@ -1153,3 +760,48 @@ def test_load_time_entry_activities_returns_empty_on_failure(capsys): captured = capsys.readouterr() assert "activit" in captured.err.lower() + + +# -- Границы периода передаются в API как есть -- + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_fetch_passes_date_boundaries_to_api_unchanged(mock_redmine_class): + """Флоринг границ выполняет cli-парсер, клиент отдаёт даты в filter как есть.""" + mock_redmine = mock_redmine_class.return_value + mock_redmine.time_entry.filter.return_value = [] + + fetch_issues_with_spent_time("2026-09-30", "2026-10-31", user_id=458) + + mock_redmine.time_entry.filter.assert_called_once_with( + user_id=458, from_date="2026-09-30", to_date="2026-10-31" + ) + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_fetch_works_without_dedup_argument(mock_redmine_class): + """dedup-слой удалён: без dedup-аргумента все записи периода агрегируются.""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine, user_id=123) + + entry = mock.MagicMock() + entry.issue.id = 1 + entry.hours = 2.0 + entry.created_on = None + entry.updated_on = None + mock_redmine.time_entry.filter.return_value = [entry] + + mock_issue = mock.MagicMock() + mock_issue.id = 1 + mock_issue.project = "P" + mock_issue.subject = "T" + mock_issue.status = "New" + mock_redmine.issue.filter.return_value = [mock_issue] + + result = fetch_issues_with_spent_time("2026-09-30", "2026-09-30", user_id=123) + + assert result is not None + assert [r[0].id for r in result] == [1] + assert result[0][1] == 2.0 diff --git a/tests/test_config.py b/tests/test_config.py index 1599d34..fe5b79e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,4 @@ +import contextlib import os import tempfile from pathlib import Path @@ -8,6 +9,18 @@ import pytest from redmine_reporter.config import AppConfig, Config, EmailConfig +@contextlib.contextmanager +def mock_today(fake_today): + """Подменяет date.today() в config, сохраняя реальные date() и fromisoformat().""" + from datetime import date as real_date + + with mock.patch("redmine_reporter.config.date") as mock_date: + mock_date.today.return_value = fake_today + mock_date.side_effect = lambda *a, **kw: real_date(*a, **kw) + mock_date.fromisoformat.side_effect = real_date.fromisoformat + yield + + @mock.patch.dict( os.environ, { @@ -62,25 +75,9 @@ def test_get_author_fallback(): assert Config.get_author("") == "" -@mock.patch.dict( - os.environ, - { - "DEFAULT_FROM_DATE": "2026-01-01", - "DEFAULT_TO_DATE": "2026-01-31", - }, - clear=True, -) +@mock.patch.dict(os.environ, {"FROM_DATE": "2026-01-01"}, clear=True) def test_get_default_date_range_from_env(): - assert Config.get_default_date_range() == "2026-01-01--2026-01-31" - - -@mock.patch.dict( - os.environ, - {"DEFAULT_FROM_DATE": "2026-01-01"}, - clear=True, -) -def test_get_default_date_range_from_env_without_to(): - """Если DEFAULT_TO_DATE не задан, конец периода — сегодня.""" + """FROM_DATE задаёт начало периода, конец всегда -- сегодня.""" from datetime import date today = date.today().isoformat() @@ -99,25 +96,6 @@ def test_get_default_date_range_fallback(): assert result == f"{start.isoformat()}--{today.isoformat()}" -@mock.patch.dict(os.environ, {}, clear=True) -def test_get_default_date_range_datetime_dynamic_sliding_window(): - app = AppConfig( - period_precision="datetime", - period_dynamic=True, - period_last_used_to="2026-09-10T15:00:00+00:00", - ) - Config._app = app - try: - real_dt = __import__("datetime").datetime - with mock.patch("redmine_reporter.config.datetime") as dt: - dt.fromisoformat = real_dt.fromisoformat - dt.now.return_value = real_dt(2026, 9, 15, 10, 0, 0) - rng = Config.get_default_date_range() - finally: - Config._app = None - assert rng == "2026-09-10T15:00:00--2026-09-15T10:00:00" - - # -- #56: дефолтный период — текущий месяц (детерминированные тесты) -- @@ -145,31 +123,98 @@ class TestDefaultPeriodIsCurrentMonth: 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 нет → конец периода = сегодня.""" + def test_yaml_period_from_runs_to_today(self): + """period.from задан -- конец периода всегда сегодня.""" from datetime import date with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" - yaml_path.write_text("period:\n default_from: '2026-04-01'\n") + yaml_path.write_text("period:\n 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) + with mock_today(date(2026, 4, 20)): 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 нет → конец = сегодня.""" + +class TestDefaultDateRangePeriodFrom: + """Приоритет окна: env FROM_DATE → YAML period.from → текущий месяц.""" + + @mock.patch.dict(os.environ, {"FROM_DATE": "2026-02-01"}, clear=True) + def test_env_from_runs_to_today(self): + """FROM_DATE задаёт начало, конец всегда сегодня.""" from datetime import date Config._app = None - with mock.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) + with mock_today(date(2026, 2, 10)): assert Config.get_default_date_range() == "2026-02-01--2026-02-10" + @mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True) + def test_env_from_wins_over_yaml_period_from(self): + """env FROM_DATE имеет приоритет над YAML period.from.""" + from datetime import date + + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("period:\n from: '2026-03-01'\n") + Config._app = AppConfig.from_yaml(yaml_path) + + with mock_today(date(2026, 7, 15)): + assert Config.get_default_date_range() == "2026-07-01--2026-07-15" + + @mock.patch.dict(os.environ, {"FROM_DATE": "2026-12-31"}, clear=True) + def test_env_from_in_future_clamps_to_today(self): + """from позже сегодня -- окно 'сегодня--сегодня', без инверсии.""" + from datetime import date + + Config._app = None + with mock_today(date(2026, 9, 15)): + assert Config.get_default_date_range() == "2026-09-15--2026-09-15" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_yaml_period_from_in_future_clamps_to_today(self): + """YAML period.from позже сегодня -- окно 'сегодня--сегодня'.""" + from datetime import date + + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("period:\n from: '2026-12-31'\n") + Config._app = AppConfig.from_yaml(yaml_path) + + with mock_today(date(2026, 9, 15)): + assert Config.get_default_date_range() == "2026-09-15--2026-09-15" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_malformed_yaml_period_from_warns_and_uses_current_month(self, caplog): + """period.from не дата -- warning в лог, окно = текущий месяц.""" + import logging + from datetime import date + + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("period:\n from: 'not-a-date'\n") + Config._app = AppConfig.from_yaml(yaml_path) + + with caplog.at_level(logging.WARNING): + with mock_today(date(2026, 9, 15)): + rng = Config.get_default_date_range() + + assert rng == "2026-09-01--2026-09-15" + assert "not-a-date" in caplog.text + + @mock.patch.dict(os.environ, {"FROM_DATE": "garbage"}, clear=True) + def test_malformed_env_from_warns_and_uses_current_month(self, caplog): + """FROM_DATE не дата -- warning в лог, окно = текущий месяц.""" + import logging + from datetime import date + + Config._app = None + with caplog.at_level(logging.WARNING): + with mock_today(date(2026, 9, 15)): + rng = Config.get_default_date_range() + + assert rng == "2026-09-01--2026-09-15" + assert "garbage" in caplog.text + @mock.patch.dict(os.environ, {}, clear=True) def test_get_redmine_verify_default(): @@ -318,13 +363,7 @@ redmine: verify_ssl: false period: - precision: datetime - default_from: "2026-06-01" - default_to: "2026-06-30" - dynamic: true - last_used: - from: "2026-06-30T09:00:00" - to: "2026-06-30T12:00:00" + from: "2026-06-01" output: dir: ~/reports @@ -363,12 +402,7 @@ class TestAppConfigFromYaml: assert cfg.redmine_api_key == "" # ${MISSING_API_KEY_FOR_TEST} not set assert cfg.redmine_author == "Тестовый Автор" assert cfg.redmine_verify is False - assert cfg.period_precision == "datetime" - assert cfg.period_default_from == "2026-06-01" - assert cfg.period_default_to == "2026-06-30" - assert cfg.period_dynamic is True - assert cfg.period_last_used_from == "2026-06-30T09:00:00" - assert cfg.period_last_used_to == "2026-06-30T12:00:00" + assert cfg.period_from == "2026-06-01" assert cfg.output_dir == "~/reports" assert cfg.output_filename == "{author}_{from}_{to}.{ext}" assert cfg.output_default_format == "xlsx" @@ -391,7 +425,7 @@ class TestAppConfigFromYaml: assert cfg.redmine_url == "https://x.com/" # defaults for everything else assert cfg.redmine_author == "" - assert cfg.period_precision == "date" + assert cfg.period_from == "" assert cfg.output_dir == "" assert cfg.email.to == [] @@ -404,7 +438,36 @@ class TestAppConfigFromYaml: assert cfg.redmine_url == "" assert cfg.redmine_api_key == "" - assert cfg.period_precision == "date" + assert cfg.period_from == "" + + def test_period_from_loaded_from_yaml(self): + """period.from читается из YAML как единственный ключ периода.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("period:\n from: '2026-06-01'\n") + + cfg = AppConfig.from_yaml(yaml_path) + + assert cfg.period_from == "2026-06-01" + + def test_old_period_keys_are_ignored(self): + """Устаревшие ключи period молча игнорируются -- читается только from.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "period:\n" + " precision: datetime\n" + " dynamic: true\n" + " default_from: '2026-01-01'\n" + " default_to: '2026-01-31'\n" + " last_used:\n" + " from: '2026-01-31T09:00:00'\n" + " to: '2026-01-31T12:00:00'\n" + ) + + cfg = AppConfig.from_yaml(yaml_path) + + assert cfg.period_from == "" def test_file_not_found_returns_all_defaults(self): cfg = AppConfig.from_yaml("/nonexistent/path/config.yml") @@ -552,14 +615,15 @@ class TestConfigYamlFallback: @mock.patch.dict(os.environ, {}, clear=True) def test_date_range_from_yaml(self): + from datetime import date + with tempfile.TemporaryDirectory() as tmp: yaml_path = Path(tmp) / "config.yml" - yaml_path.write_text( - "period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n" - ) + yaml_path.write_text("period:\n from: '2026-03-01'\n") Config._app = AppConfig.from_yaml(yaml_path) + with mock_today(date(2026, 3, 15)): assert Config.get_default_date_range() == "2026-03-01--2026-03-15" @mock.patch.dict(os.environ, {}, clear=True) @@ -589,205 +653,17 @@ class TestConfigYamlFallback: @mock.patch.dict(os.environ, {}, clear=True) def test_default_date_range_env_beats_yaml(self): - with tempfile.TemporaryDirectory() as tmp: - yaml_path = Path(tmp) / "config.yml" - yaml_path.write_text( - "period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n" - ) - - Config._app = AppConfig.from_yaml(yaml_path) - - with mock.patch.dict( - os.environ, - {"DEFAULT_FROM_DATE": "2026-07-01", "DEFAULT_TO_DATE": "2026-07-15"}, - clear=True, - ): - assert Config.get_default_date_range() == "2026-07-01--2026-07-15" - - -class TestConfigPeriodPrecision: - """Tests for period.precision and period.last_used.""" - - @mock.patch.dict(os.environ, {}, clear=True) - def test_get_period_precision_defaults_to_date(self): - """Без YAML-конфига precision == 'date'.""" - Config._app = None - assert Config.get_period_precision() == "date" - - @mock.patch.dict(os.environ, {}, clear=True) - def test_get_period_precision_from_yaml(self): - with tempfile.TemporaryDirectory() as tmp: - yaml_path = Path(tmp) / "config.yml" - yaml_path.write_text("period:\n precision: datetime\n") - - Config._app = AppConfig.from_yaml(yaml_path) - assert Config.get_period_precision() == "datetime" - - @mock.patch.dict(os.environ, {}, clear=True) - def test_get_last_used_from_yaml(self): - with tempfile.TemporaryDirectory() as tmp: - yaml_path = Path(tmp) / "config.yml" - yaml_path.write_text( - "period:\n last_used:\n from: '2026-06-30T09:00:00'\n to: '2026-06-30T12:00:00'\n" - ) - - Config._app = AppConfig.from_yaml(yaml_path) - assert Config.get_last_used_from() == "2026-06-30T09:00:00" - assert Config.get_last_used_to() == "2026-06-30T12:00:00" - - @mock.patch.dict(os.environ, {}, clear=True) - def test_get_last_used_returns_empty_when_not_set(self): - Config._app = AppConfig() - assert Config.get_last_used_from() == "" - assert Config.get_last_used_to() == "" - - @mock.patch.dict(os.environ, {}, clear=True) - def test_yaml_loads_last_used_field(self): - with tempfile.TemporaryDirectory() as tmp: - yaml_path = Path(tmp) / "config.yml" - yaml_path.write_text( - "period:\n last_used:\n from: '2026-07-01T08:00:00'\n to: '2026-07-01T18:00:00'\n" - ) - - cfg = AppConfig.from_yaml(yaml_path) - assert cfg.period_last_used_from == "2026-07-01T08:00:00" - assert cfg.period_last_used_to == "2026-07-01T18:00:00" - - @mock.patch.dict(os.environ, {}, clear=True) - def test_yaml_without_last_used_has_empty_fields(self): - with tempfile.TemporaryDirectory() as tmp: - yaml_path = Path(tmp) / "config.yml" - yaml_path.write_text("period:\n precision: date\n") - - cfg = AppConfig.from_yaml(yaml_path) - assert cfg.period_last_used_from == "" - assert cfg.period_last_used_to == "" - - -class TestComputeNextPeriod: - """Tests for compute_next_period().""" - - def test_full_month_goes_to_next_full_month(self): - from redmine_reporter.config import compute_next_period - - nf, nt = compute_next_period("2026-06-01", "2026-06-30", "date") - assert nf == "2026-07-01" - assert nt == "2026-07-31" - - def test_december_to_next_year_january(self): - from redmine_reporter.config import compute_next_period - - nf, nt = compute_next_period("2025-12-01", "2025-12-31", "date") - assert nf == "2026-01-01" - assert nt == "2026-01-31" - - def test_february_2026_non_leap_to_march(self): - from redmine_reporter.config import compute_next_period - - nf, nt = compute_next_period("2026-02-01", "2026-02-28", "date") - assert nf == "2026-03-01" - assert nt == "2026-03-31" - - def test_arbitrary_range_same_length_from_to_plus_one(self): - from redmine_reporter.config import compute_next_period - - nf, nt = compute_next_period("2026-06-15", "2026-06-20", "date") - assert nf == "2026-06-21" - assert nt == "2026-06-26" - - def test_single_day_range_moves_one_day(self): - from redmine_reporter.config import compute_next_period - - nf, nt = compute_next_period("2026-06-15", "2026-06-15", "date") - assert nf == "2026-06-16" - assert nt == "2026-06-16" - - def test_cross_month_range(self): - from redmine_reporter.config import compute_next_period - - nf, nt = compute_next_period("2026-06-25", "2026-07-05", "date") - assert nf == "2026-07-06" - assert nt == "2026-07-16" - - def test_datetime_precision_moves_by_seconds(self): - from redmine_reporter.config import compute_next_period - - nf, nt = compute_next_period( - "2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime" - ) - assert nf == "2026-06-30T12:00:01" - assert nt == "2026-06-30T15:00:01" - - -class TestDefaultDateRangeWithLastUsed: - """Tests that get_default_date_range() uses last_used when dynamic=True.""" - - @mock.patch.dict(os.environ, {}, clear=True) - def test_uses_last_used_when_dynamic_true(self): - with tempfile.TemporaryDirectory() as tmp: - yaml_path = Path(tmp) / "config.yml" - yaml_path.write_text( - "period:\n" - " precision: date\n" - " dynamic: true\n" - " last_used:\n" - " from: '2026-05-01'\n" - " to: '2026-05-31'\n" - ) - Config._app = AppConfig.from_yaml(yaml_path) - result = Config.get_default_date_range() - assert result == "2026-06-01--2026-06-30" - - @mock.patch.dict(os.environ, {}, clear=True) - def test_ignores_last_used_when_dynamic_false(self): - with tempfile.TemporaryDirectory() as tmp: - yaml_path = Path(tmp) / "config.yml" - yaml_path.write_text( - "period:\n" - " precision: date\n" - " dynamic: false\n" - " default_from: '2026-03-01'\n" - " default_to: '2026-03-15'\n" - " last_used:\n" - " from: '2026-06-01'\n" - " to: '2026-06-30'\n" - ) - Config._app = AppConfig.from_yaml(yaml_path) - result = Config.get_default_date_range() - assert result == "2026-03-01--2026-03-15" - - @mock.patch.dict(os.environ, {}, clear=True) - def test_falls_back_when_no_last_used_even_with_dynamic(self): - with tempfile.TemporaryDirectory() as tmp: - yaml_path = Path(tmp) / "config.yml" - yaml_path.write_text( - "period:\n" - " precision: date\n" - " dynamic: true\n" - " default_from: '2026-04-01'\n" - " default_to: '2026-04-15'\n" - ) - Config._app = AppConfig.from_yaml(yaml_path) - result = Config.get_default_date_range() - assert result == "2026-04-01--2026-04-15" - - @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" - ) + yaml_path.write_text("period:\n from: '2026-03-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}" + + with mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True): + with mock_today(date(2026, 7, 15)): + assert Config.get_default_date_range() == "2026-07-01--2026-07-15" class TestGetEmailConfig: diff --git a/tests/test_yaml_config.py b/tests/test_yaml_config.py index e6adbb2..e26299b 100644 --- a/tests/test_yaml_config.py +++ b/tests/test_yaml_config.py @@ -4,11 +4,14 @@ import tempfile from pathlib import Path from unittest import mock +import yaml + from redmine_reporter.yaml_config import ( check_file_permissions, ensure_config_dir, expand_filename_template, resolve_env_vars, + save_period_to_config, ) @@ -211,120 +214,65 @@ class TestResolveOutputPath: class TestSavePeriodToConfig: """Tests for save_period_to_config().""" - def test_creates_last_used_in_empty_config(self, tmp_path): - import yaml - - from redmine_reporter.yaml_config import save_period_to_config - + def test_creates_period_from_in_empty_config(self, tmp_path): config_path = tmp_path / "config.yml" - save_period_to_config( - str(config_path), "2026-06-01", "2026-06-30", "date", True - ) + config_path.write_text("") + + save_period_to_config(str(config_path), "2026-06-01") with open(config_path) as fh: data = yaml.safe_load(fh) - assert data["period"]["last_used"]["from"] == "2026-06-01" - assert data["period"]["last_used"]["to"] == "2026-06-30" - assert "default_from" not in data["period"] - - def test_overwrites_existing_last_used(self, tmp_path): - import yaml - - from redmine_reporter.yaml_config import save_period_to_config + assert data["period"]["from"] == "2026-06-01" + def test_overwrites_existing_period_from(self, tmp_path): config_path = tmp_path / "config.yml" - config_path.write_text( - "period:\n last_used:\n from: '2026-05-01'\n to: '2026-05-31'\n" - ) + config_path.write_text("period:\n from: '2026-05-01'\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") with open(config_path) as fh: data = yaml.safe_load(fh) - assert data["period"]["last_used"]["from"] == "2026-06-01" - assert data["period"]["last_used"]["to"] == "2026-06-30" - - def test_dynamic_false_overwrites_defaults(self, tmp_path): - import yaml - - from redmine_reporter.yaml_config import save_period_to_config - - config_path = tmp_path / "config.yml" - config_path.write_text( - "period:\n default_from: '2026-01-01'\n default_to: '2026-01-31'\n" - ) - - save_period_to_config( - str(config_path), "2026-06-01", "2026-06-30", "date", False - ) - - with open(config_path) as fh: - data = yaml.safe_load(fh) - - assert data["period"]["default_from"] == "2026-06-01" - assert data["period"]["default_to"] == "2026-06-30" - assert data["period"]["last_used"]["from"] == "2026-06-01" - assert data["period"]["last_used"]["to"] == "2026-06-30" - - def test_datetime_precision_stores_timestamps(self, tmp_path): - import yaml - - from redmine_reporter.yaml_config import save_period_to_config - - config_path = tmp_path / "config.yml" - - save_period_to_config( - str(config_path), - "2026-06-30T09:00:00", - "2026-06-30T12:00:00", - "datetime", - True, - ) - - with open(config_path) as fh: - data = yaml.safe_load(fh) - - assert data["period"]["last_used"]["from"] == "2026-06-30T09:00:00" - assert data["period"]["last_used"]["to"] == "2026-06-30T12:00:00" - - def test_preserves_existing_sections(self, tmp_path): - import yaml - - from redmine_reporter.yaml_config import save_period_to_config + assert data["period"]["from"] == "2026-06-01" + def test_preserves_other_sections_and_keys(self, tmp_path): config_path = tmp_path / "config.yml" config_path.write_text( "redmine:\n" " url: https://example.com\n" " author: Test\n" + "email:\n" + " enabled: true\n" "output:\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") with open(config_path) as fh: data = yaml.safe_load(fh) - assert data["redmine"]["url"] == "https://example.com" - assert data["redmine"]["author"] == "Test" - assert data["output"]["dir"] == "/tmp" - assert data["period"]["last_used"]["from"] == "2026-06-01" + assert data["redmine"] == {"url": "https://example.com", "author": "Test"} + assert data["email"] == {"enabled": True} + assert data["output"] == {"dir": "/tmp"} + assert data["period"] == {"from": "2026-06-01"} def test_sets_permissions_0600(self, tmp_path): - from redmine_reporter.yaml_config import save_period_to_config - config_path = tmp_path / "config.yml" - save_period_to_config( - str(config_path), "2026-06-01", "2026-06-30", "date", True - ) + save_period_to_config(str(config_path), "2026-06-01") mode = config_path.stat().st_mode & 0o777 assert mode == 0o600 + + def test_creates_config_file_and_dir_when_missing(self, tmp_path): + config_path = tmp_path / "nested" / "config.yml" + assert not config_path.exists() + + save_period_to_config(str(config_path), "2026-06-01") + + assert config_path.exists() + with open(config_path) as fh: + data = yaml.safe_load(fh) + assert data["period"]["from"] == "2026-06-01"