7 Commits

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

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

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

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

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

Closes #38
2026-06-29 14:41:37 +07:00
17 changed files with 934 additions and 157 deletions

6
.gitignore vendored
View File

@@ -88,7 +88,7 @@ secrets.json
# Just in case # Just in case
.~* .~*
report.odt # Local report outputs
report.csv report.*
report.md rep.*
*.html *.html

126
README.md
View File

@@ -8,18 +8,15 @@ CLI-инструмент для генерации отчётов по зада
## Возможности ## Возможности
- Получение time entries текущего пользователя из Redmine. - Получение time entries **текущего** или **указанного** пользователя из Redmine.
- Авторизация через Redmine API token. - Авторизация через Redmine API token или логин/пароль.
- Резервная авторизация через логин и пароль для обратной совместимости.
- Группировка задач по проекту и версии. - Группировка задач по проекту и версии.
- Перевод статусов задач на русский язык. - Перевод статусов задач на русский язык.
- Вывод в консоль в табличном или компактном виде. - Вывод в консоль (таблица / компактный вид).
- Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx). - Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
- Сводка по затраченному времени (итоги и разбивка по проектам/версиям). - Excel-отчёт с merge-ячейками по проекту/версии, итогами, автошириной, автофильтром и закреплённой шапкой.
- Автоматическое определение месяца ODT-отчёта по конечной дате периода. - Сводка по времени (`--summary`).
- Настройка периода отчёта по умолчанию через `.env` (или автоматически — текущий месяц). - Понятные сообщения об ошибках Redmine API (401/403/5xx, таймаут, сеть).
- Переопределение URL и API-ключа через CLI.
- Загрузка альтернативного `.env` через `--config`.
- Загрузка альтернативного `.env` через `--config`. - Загрузка альтернативного `.env` через `--config`.
## Установка ## Установка
@@ -33,9 +30,15 @@ pip install --upgrade pip
pip install . pip install .
``` ```
Для разработки:
```bash
pip install -e ".[dev]"
```
## Настройка ## Настройка
Создайте файл `.env` в корне проекта. Файл не должен попадать в git. Создайте файл `.env` в корне проекта. Он не должен попадать в git.
Рекомендуемый вариант авторизации: Рекомендуемый вариант авторизации:
@@ -48,18 +51,13 @@ DEFAULT_FROM_DATE=2026-01-01
DEFAULT_TO_DATE=2026-01-31 DEFAULT_TO_DATE=2026-01-31
``` ```
Если задан `REDMINE_API_KEY`, он используется в первую очередь. Значения из `.env` можно переопределить через CLI: `--url`, `--api-key`, `--author`, а также загрузить другой файл конфигурации через `--config`. Резервный вариант:
Резервный вариант авторизации:
```ini ```ini
REDMINE_URL=https://red.eltex.loc/ REDMINE_URL=https://red.eltex.loc/
REDMINE_USER=ваш.логин REDMINE_USER=ваш.логин
REDMINE_PASSWORD=ваш_пароль REDMINE_PASSWORD=ваш_пароль
REDMINE_AUTHOR=Иванов Иван Иванович REDMINE_AUTHOR=Иванов Иван Иванович
DEFAULT_FROM_DATE=2026-01-01
DEFAULT_TO_DATE=2026-01-31
``` ```
Переменные окружения: Переменные окружения:
@@ -71,18 +69,9 @@ DEFAULT_TO_DATE=2026-01-31
| `REDMINE_USER` | Да, если нет токена | Логин Redmine. | | `REDMINE_USER` | Да, если нет токена | Логин Redmine. |
| `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. | | `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. |
| `REDMINE_AUTHOR` | Нет | Имя автора для ODT-отчёта. | | `REDMINE_AUTHOR` | Нет | Имя автора для ODT-отчёта. |
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию в формате `YYYY-MM-DD`. | | `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию (`YYYY-MM-DD`). |
| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию в формате `YYYY-MM-DD`. | | `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию (`YYYY-MM-DD`). |
| `REDMINE_VERIFY` | Нет | Настройка TLS-проверки для Redmine API. | | `REDMINE_VERIFY` | Нет | TLS-проверка: `true` / `false` / путь к CA bundle. |
`REDMINE_VERIFY` поддерживает значения:
- пустое значение или отсутствие переменной: `/etc/ssl/certs/ca-certificates.crt`;
- `true`, `1`, `yes`, `on`: стандартная проверка сертификатов `requests`;
- `false`, `0`, `no`, `off`: отключить проверку сертификатов;
- любой другой текст: путь к CA bundle.
Отключать проверку сертификатов не рекомендуется.
## Использование ## Использование
@@ -90,7 +79,7 @@ DEFAULT_TO_DATE=2026-01-31
source .venv/bin/activate source .venv/bin/activate
``` ```
Отчёт за период по умолчанию (текущий месяц или из `.env`): Отчёт за период по умолчанию:
```bash ```bash
redmine-reporter redmine-reporter
@@ -102,15 +91,23 @@ redmine-reporter
redmine-reporter --date 2026-02-01--2026-02-28 redmine-reporter --date 2026-02-01--2026-02-28
``` ```
Период должен быть задан в формате `YYYY-MM-DD--YYYY-MM-DD`. Начальная дата не может быть позже конечной. Отчёт по другому пользователю:
Переопределение URL и API-ключа из `.env`: ```bash
redmine-reporter --user-id 42
redmine-reporter --user-login ivanov
redmine-reporter --user-name "Иванов И.И."
```
`--user-name` требует точного совпадения; если найдено несколько пользователей, CLI сообщает об ошибке и просит использовать `--user-id`.
Переопределить URL/API-ключ из `.env`:
```bash ```bash
redmine-reporter --url https://red.example.com --api-key ваш_токен redmine-reporter --url https://red.example.com --api-key ваш_токен
``` ```
Использование альтернативного конфигурационного файла: Альтернативный конфигурационный файл:
```bash ```bash
redmine-reporter --config /path/to/.env redmine-reporter --config /path/to/.env
@@ -122,19 +119,12 @@ redmine-reporter --config /path/to/.env
redmine-reporter --compact redmine-reporter --compact
``` ```
Подробный или отладочный вывод: Отладочный вывод:
```bash ```bash
redmine-reporter --verbose
redmine-reporter --debug redmine-reporter --debug
``` ```
Вывод версии:
```bash
redmine-reporter --version
```
Экспорт: Экспорт:
```bash ```bash
@@ -146,60 +136,40 @@ redmine-reporter --output report.json
redmine-reporter --output report.xlsx redmine-reporter --output report.xlsx
``` ```
JSON-отчёт: Отчёт без затраченного времени (работает для всех форматов):
- массив объектов с полями `project`, `version`, `issue_id`, `subject`, `status`, `time`;
- UTF-8, читаемый машиной.
Excel-отчёт (.xlsx):
- одна таблица с заголовками и строками данных;
- шапка выделена жирным;
- каждая строка содержит полные значения проекта и версии.
CSV-отчёт:
- файл сохраняется в UTF-8 с BOM (`utf-8-sig`) для корректного отображения кириллицы в Microsoft Excel;
- каждая строка содержит полные значения проекта и версии (в отличие от консольного и Markdown-вывода, где повторяющиеся значения скрыты для компактности).
HTML-отчёт:
- полноценный HTML-документ с `<meta charset="utf-8">`;
- корректно отображается в браузере и почтовых клиентах.
ODT-отчёт:
- месяц в заголовке определяется по `to_date`;
- имя автора берётся из `--author`, затем из `REDMINE_AUTHOR`;
- если автор не задан, поле автора остаётся пустым.
Вывод без затраченного времени:
```bash ```bash
redmine-reporter --no-time redmine-reporter --no-time
redmine-reporter --no-time --output report.xlsx
``` ```
Сводка по времени (итоги и разбивка по проектам): Сводка по времени:
```bash ```bash
redmine-reporter --summary redmine-reporter --summary
``` ```
## Форматы вывода
| Формат | Особенности |
| --- | --- |
| **ODT** | Заголовок с автором и месяцем, группировка по проекту/версии. |
| **CSV** | UTF-8 с BOM, полные значения `project`/`version` в каждой строке. |
| **Markdown** | Компактная таблица, повторяющиеся группы скрыты. |
| **HTML** | Полноценный HTML-документ с `meta charset="utf-8"`. |
| **JSON** | Массив объектов: `project`, `version`, `issue_id`, `subject`, `status`, `time`. |
| **Excel (.xlsx)** | Merge cells, колонки `Hours`/`Spent Time`, итоги, автоширина, автофильтр, freeze panes. |
## Разработка ## Разработка
Установка зависимостей для разработки: Проверки перед коммитом:
```bash
pip install -e ".[dev]"
```
Проверки:
```bash ```bash
pytest pytest
ruff check redmine_reporter tests ruff check redmine_reporter tests
black redmine_reporter tests black --check redmine_reporter tests
isort redmine_reporter tests isort --check-only redmine_reporter tests
mypy redmine_reporter
``` ```
## Безопасность ## Безопасность

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "redmine-reporter" name = "redmine-reporter"
version = "1.6.0" version = "1.8.1"
description = "Redmine time-entry based issue reporter for internal use" description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md" readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]

View File

@@ -1 +1 @@
__version__ = "1.6.0" __version__ = "1.8.1"

View File

@@ -7,7 +7,7 @@ from datetime import datetime
from typing import List, Optional from typing import List, Optional
from . import __version__ from . import __version__
from .client import fetch_issues_with_spent_time from .client import RedmineAPIError, fetch_issues_with_spent_time
from .config import Config from .config import Config
from .formatters.factory import get_console_formatter, get_formatter_by_extension from .formatters.factory import get_console_formatter, get_formatter_by_extension
from .report_builder import build_grouped_report, calculate_summary from .report_builder import build_grouped_report, calculate_summary
@@ -76,8 +76,34 @@ def main(argv: Optional[List[str]] = None) -> int:
action="store_true", action="store_true",
help="Print summary (total hours by project/version) to stderr", help="Print summary (total hours by project/version) to stderr",
) )
parser.add_argument(
"--user-id",
help="Redmine user ID for the report (default: current user)",
)
parser.add_argument(
"--user-login",
help="Redmine user login for the report (alternative to --user-id)",
)
parser.add_argument(
"--user-name",
help="Redmine user full name for the report (alternative to --user-id; ambiguous names are rejected)",
)
parser.add_argument(
"--by-activity",
action="store_true",
help="Break down spent time by activity type",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
# Валидация взаимоисключающих флагов пользователя
user_args = [args.user_id, args.user_login, args.user_name]
if sum(bool(a) for a in user_args) > 1:
print(
"❌ Specify only one of --user-id, --user-login, or --user-name.",
file=sys.stderr,
)
return 1
# CLI-переопределения имеют приоритет над .env/env. # CLI-переопределения имеют приоритет над .env/env.
if args.config: if args.config:
Config.load_config(args.config) Config.load_config(args.config)
@@ -105,9 +131,19 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1 return 1
try: try:
issue_hours = fetch_issues_with_spent_time(from_date, to_date) issue_hours = fetch_issues_with_spent_time(
from_date,
to_date,
user_id=args.user_id or args.user_login or args.user_name,
by_activity=args.by_activity,
)
except RedmineAPIError as e:
print(f"{e.message}", file=sys.stderr)
if args.debug and e.original is not None:
logging.exception("Original Redmine API error")
return 1
except Exception as e: except Exception as e:
print(f"Redmine API error: {e}", file=sys.stderr) print(f"Unexpected error: {e}", file=sys.stderr)
return 1 return 1
if issue_hours is None: if issue_hours is None:
@@ -120,15 +156,25 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]", file=sys.stderr) print(f"✅ Total issues: {len(issue_hours)} [{args.date}]", file=sys.stderr)
rows = build_grouped_report(issue_hours, fill_time=not args.no_time) rows = build_grouped_report(
issue_hours,
fill_time=not args.no_time,
by_activity=args.by_activity,
)
if args.summary: if args.summary:
summary = calculate_summary(rows) summary = calculate_summary(rows, by_activity=args.by_activity)
print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr) print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr)
for key, value in summary.items(): project_keys = [k for k in sorted(summary) if k.startswith("project:")]
activity_keys = [k for k in sorted(summary) if k.startswith("activity:")]
for key in project_keys + activity_keys:
value = summary[key]
if key.startswith("project:"): if key.startswith("project:"):
project = key.split(":", 1)[1] project = key.split(":", 1)[1]
print(f" {project}: {value}h", file=sys.stderr) print(f" {project}: {value}h", file=sys.stderr)
elif key.startswith("activity:"):
activity = key.split(":", 1)[1]
print(f" [{activity}]: {value}h", file=sys.stderr)
if args.output: if args.output:
output_ext = os.path.splitext(args.output)[1].lower() output_ext = os.path.splitext(args.output)[1].lower()
@@ -145,6 +191,7 @@ def main(argv: Optional[List[str]] = None) -> int:
author=Config.get_author(args.author), author=Config.get_author(args.author),
from_date=from_date, from_date=from_date,
to_date=to_date, to_date=to_date,
no_time=args.no_time,
) )
if not formatter: if not formatter:

View File

@@ -1,7 +1,8 @@
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple, Union
import requests import requests
from redminelib import Redmine from redminelib import Redmine
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
from redminelib.resources import Issue from redminelib.resources import Issue
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
@@ -14,6 +15,15 @@ REQUEST_TIMEOUT = 30
ISSUE_ID_CHUNK_SIZE = 100 ISSUE_ID_CHUNK_SIZE = 100
class RedmineAPIError(Exception):
"""Пользовательское исключение с понятным сообщением об ошибке Redmine API."""
def __init__(self, message: str, original: Optional[Exception] = None):
super().__init__(message)
self.message = message
self.original = original
def _get_redmine_auth_kwargs() -> Dict[str, Any]: def _get_redmine_auth_kwargs() -> Dict[str, Any]:
"""Return Redmine auth kwargs. API key has priority over legacy password auth.""" """Return Redmine auth kwargs. API key has priority over legacy password auth."""
api_key = Config.get_redmine_api_key() api_key = Config.get_redmine_api_key()
@@ -56,6 +66,84 @@ def _create_redmine() -> Redmine:
return redmine return redmine
def _format_redmine_error(exc: Exception) -> str:
"""Преобразует исключение Redmine/requests в понятное сообщение."""
if isinstance(exc, AuthError):
return (
"Authentication failed: invalid API key, login or password. "
"Check REDMINE_API_KEY / REDMINE_USER / REDMINE_PASSWORD."
)
if isinstance(exc, ForbiddenError):
return (
"Access denied: your Redmine account does not have permission "
"to read time entries or issues."
)
if isinstance(exc, ResourceNotFoundError):
return "Requested Redmine resource not found: check user/project identifiers."
# requests HTTPError может быть обёрнуто в python-redmine
original = getattr(exc, "response", None)
if original is None:
original = exc
response = getattr(original, "response", None)
if response is not None and hasattr(response, "status_code"):
status = response.status_code
if status == 401:
return "Authentication failed (HTTP 401): check your API key or login/password."
if status == 403:
return "Access denied (HTTP 403): insufficient Redmine permissions."
if status == 404:
return "Redmine endpoint not found (HTTP 404): check REDMINE_URL."
if status == 429:
return "Too many requests (HTTP 429): Redmine rate limit exceeded."
if 500 <= status < 600:
return f"Redmine server error (HTTP {status}): try again later."
return f"Redmine API returned HTTP {status}."
if isinstance(exc, requests.exceptions.Timeout):
return f"Redmine request timed out after {REQUEST_TIMEOUT} seconds."
if isinstance(exc, requests.exceptions.ConnectionError):
return "Cannot connect to Redmine: check the URL and network."
if isinstance(exc, requests.exceptions.RequestException):
return f"Network error while calling Redmine: {exc}"
return str(exc)
def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
"""Загружает справочник типов активности time entries.
Возвращает словарь id -> name. Если справочник недоступен,
возвращает пустой словарь — тогда будем использовать данные из самих entries.
"""
try:
activities = redmine.enumeration.filter(resource="time_entry_activities")
return {int(a.id): str(a.name) for a in activities}
except Exception:
return {}
def _get_activity_name(entry, activities: Dict[int, str]) -> str:
"""Определяет название активности для time entry."""
activity = getattr(entry, "activity", None)
if activity is None:
return "<N/A>"
# activity может быть объектом с id/name или просто значением
activity_id = getattr(activity, "id", None)
if activity_id is not None:
name = activities.get(int(activity_id))
if name:
return name
activity_name = getattr(activity, "name", None)
if activity_name:
return str(activity_name)
return str(activity_id)
return str(activity)
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]: def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = [] all_issues: List[Issue] = []
@@ -67,44 +155,131 @@ def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]
return all_issues return all_issues
def fetch_issues_with_spent_time( def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int:
from_date: str, to_date: str """Преобразует строковый идентификатор пользователя в числовой ID.
) -> Optional[List[Tuple[Issue, float]]]:
"""
Fetch unique issues linked to time entries of the current user in given date range,
along with total spent hours per issue.
Returns list of (issue, total_hours) tuples.
"""
redmine = _create_redmine() Если аргумент — число, возвращает его как есть.
Если строка, пытается найти пользователя по логину или имени.
"""
if isinstance(user_arg, int):
return user_arg
current_user = redmine.user.get("current") text = str(user_arg).strip()
time_entries = redmine.time_entry.filter( if not text:
user_id=current_user.id, from_date=from_date, to_date=to_date raise RedmineAPIError("User identifier cannot be empty.")
# Сначала пробуем интерпретировать как числовой ID
if text.isdigit():
return int(text)
# Затем ищем по логину
try:
users = redmine.user.filter(login=text)
if users:
return int(users[0].id)
except Exception as exc:
raise RedmineAPIError(
f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}",
original=exc,
) from exc
# Потом по имени
try:
users = redmine.user.filter(name=text)
if len(users) == 1:
return int(users[0].id)
if len(users) > 1:
matches = ", ".join(str(getattr(u, "login", u.id)) for u in users[:5])
raise RedmineAPIError(
f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID."
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(
f"Cannot resolve user name '{text}': {_format_redmine_error(exc)}",
original=exc,
) from exc
raise RedmineAPIError(
f"User '{text}' not found. Check the login/name or use --user-id with numeric Redmine ID."
) )
# Агрегируем часы по issue.id
def _get_current_user_id(redmine: Redmine) -> int:
"""Возвращает ID текущего пользователя."""
try:
current_user = redmine.user.get("current")
return int(current_user.id)
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
def fetch_issues_with_spent_time(
from_date: str,
to_date: str,
user_id: Optional[Union[int, str]] = None,
by_activity: bool = False,
) -> 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.
Returns list of (issue, total_hours, activities) tuples.
Raises RedmineAPIError on API/auth/network failures.
"""
try:
redmine = _create_redmine()
target_user_id = (
_resolve_user_id(redmine, user_id)
if user_id is not None
else _get_current_user_id(redmine)
)
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
time_entries = redmine.time_entry.filter(
user_id=target_user_id, from_date=from_date, to_date=to_date
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Агрегируем часы по issue.id (и активности, если требуется)
spent_time: Dict[int, float] = {} spent_time: Dict[int, float] = {}
spent_by_activity: Dict[int, Dict[str, float]] = {}
issue_ids = set() issue_ids = set()
for entry in time_entries: for entry in time_entries:
if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"): if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
iid = entry.issue.id iid = entry.issue.id
hours = float(entry.hours)
issue_ids.add(iid) issue_ids.add(iid)
spent_time[iid] = spent_time.get(iid, 0.0) + float(entry.hours) spent_time[iid] = spent_time.get(iid, 0.0) + hours
if by_activity:
activity_name = _get_activity_name(entry, activities_lookup)
by_act = spent_by_activity.setdefault(iid, {})
by_act[activity_name] = by_act.get(activity_name, 0.0) + hours
if not issue_ids: if not issue_ids:
return None return None
# Загружаем полные объекты задач чанками (#21) # Загружаем полные объекты задач чанками (#21)
try:
sorted_ids = sorted(issue_ids) sorted_ids = sorted(issue_ids)
issues = _fetch_issues_chunked(redmine, sorted_ids) issues = _fetch_issues_chunked(redmine, sorted_ids)
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Сопоставляем задачи с суммарным временем. # Сопоставляем задачи с суммарным временем.
# Сортировка выполняется в report_builder.build_grouped_report, # Сортировка выполняется в report_builder.build_grouped_report,
# здесь оставляем порядок API как есть. # здесь оставляем порядок API как есть.
result = [] result = []
for issue in issues: for issue in issues:
total_hours = spent_time.get(issue.id, 0.0) iid = issue.id
result.append((issue, total_hours)) total_hours = spent_time.get(iid, 0.0)
activity_breakdown = spent_by_activity.get(iid) if by_activity else None
result.append((issue, total_hours, activity_breakdown))
return result return result

View File

@@ -12,13 +12,14 @@ class TableFormatter(Formatter):
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]] table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
for r in rows: for r in rows:
time_text = r["time_text"].replace("\n", " / ")
table_rows.append( table_rows.append(
[ [
r["display_project"], r["display_project"],
r["display_version"], r["display_version"],
f"{r['issue_id']}. {r['subject']}", f"{r['issue_id']}. {r['subject']}",
r["status_ru"], r["status_ru"],
r["time_text"], time_text,
] ]
) )
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid") return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
@@ -35,9 +36,10 @@ class CompactFormatter(Formatter):
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
lines = [] lines = []
for r in rows: for r in rows:
time_text = r["time_text"].replace("\n", " / ")
lines.append( lines.append(
f"{r['display_project']} | {r['display_version']} | " f"{r['display_project']} | {r['display_version']} | "
f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {r['time_text']}" f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {time_text}"
) )
return "\n".join(lines) return "\n".join(lines)

View File

@@ -15,24 +15,29 @@ class CSVFormatter(Formatter):
корректного отображения кириллицы в Microsoft Excel (#26). корректного отображения кириллицы в Microsoft Excel (#26).
""" """
def __init__(self, **_kwargs): def __init__(self, no_time: bool = False, **_kwargs):
super().__init__() super().__init__()
self.no_time = no_time
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output, dialect="excel") writer = csv.writer(output, dialect="excel")
writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]) headers = ["Project", "Version", "Issue ID", "Subject", "Status"]
if not self.no_time:
headers.append("Spent Time")
writer.writerow(headers)
for r in rows: for r in rows:
writer.writerow( time_text = r["time_text"].replace("\n", " / ")
[ data = [
r["project"], r["project"],
r["version"], r["version"],
r["issue_id"], r["issue_id"],
r["subject"], r["subject"],
r["status_ru"], r["status_ru"],
r["time_text"],
] ]
) if not self.no_time:
data.append(time_text)
writer.writerow(data)
return output.getvalue() return output.getvalue()
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: List[ReportRow], output_path: str) -> None:

View File

@@ -48,7 +48,7 @@ class HTMLFormatter(Formatter):
for r in task_rows: for r in task_rows:
task_cell = escape(f"{r['issue_id']}. {r['subject']}") task_cell = escape(f"{r['issue_id']}. {r['subject']}")
status_text = escape(r["status_ru"]) status_text = escape(r["status_ru"])
time_text = escape(r["time_text"]) time_text = escape(r["time_text"]).replace("\n", "<br>")
lines.append(" <tr>") lines.append(" <tr>")
# Ячейка "Проект" - только в первой строке проекта # Ячейка "Проект" - только в первой строке проекта

View File

@@ -12,8 +12,9 @@ class JSONFormatter(Formatter):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
data = [ data = []
{ for r in rows:
item = {
"project": r["project"], "project": r["project"],
"version": r["version"], "version": r["version"],
"issue_id": r["issue_id"], "issue_id": r["issue_id"],
@@ -21,8 +22,10 @@ class JSONFormatter(Formatter):
"status": r["status_ru"], "status": r["status_ru"],
"time": r["time_text"], "time": r["time_text"],
} }
for r in rows activities = r.get("activities")
] if activities:
item["activities"] = activities
data.append(item)
return json.dumps(data, ensure_ascii=False, indent=2) return json.dumps(data, ensure_ascii=False, indent=2)
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: List[ReportRow], output_path: str) -> None:

View File

@@ -15,7 +15,7 @@ from .base import Formatter
class ODTFormatter(Formatter): class ODTFormatter(Formatter):
"""Форматтер для экспорта в ODT.""" """Форматтер для экспорта в ODT."""
def __init__(self, author: str = "", from_date: str = "", to_date: str = ""): def __init__(self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs):
""" """
Инициализирует форматтер с параметрами для шапки отчета. Инициализирует форматтер с параметрами для шапки отчета.
""" """
@@ -31,6 +31,13 @@ class ODTFormatter(Formatter):
with resources.files("redmine_reporter").joinpath("templates/template.odt").open("rb") as f: with resources.files("redmine_reporter").joinpath("templates/template.odt").open("rb") as f:
doc = load(f) doc = load(f)
# Удаляем все текстовые параграфы из шаблона, оставляя только
# структурные элементы (forms, sequence-decls). Это защищает от
# артефактов редактирования шаблона в LibreOffice (#42).
for child in list(doc.text.childNodes):
if child.tagName == "text:p":
doc.text.removeChild(child)
para_style_name = "Standard" para_style_name = "Standard"
# Заголовок # Заголовок
@@ -125,8 +132,12 @@ class ODTFormatter(Formatter):
row.addElement(status_cell) row.addElement(status_cell)
time_cell = TableCell(stylename=cell_style_name) time_cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=r["time_text"]) time_lines = r["time_text"].split("\n")
for i, line in enumerate(time_lines):
p = P(stylename=para_style_name, text=line)
time_cell.addElement(p) time_cell.addElement(p)
if i < len(time_lines) - 1:
time_cell.addElement(P(stylename=para_style_name, text=""))
row.addElement(time_cell) row.addElement(time_cell)
table.addElement(row) table.addElement(row)

View File

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

View File

@@ -1,4 +1,4 @@
from typing import Dict, List, Tuple, cast from typing import Dict, List, Optional, Tuple, cast
from redminelib.resources import Issue from redminelib.resources import Issue
@@ -22,9 +22,19 @@ STATUS_TRANSLATION = {
} }
def _format_activities(activities: Dict[str, float]) -> str:
"""Форматирует разбивку по активностям в многострочный текст."""
if not activities:
return ""
# Сортируем по убыванию часов, затем по алфавиту для стабильности
items = sorted(activities.items(), key=lambda x: (-x[1], x[0]))
return "\n".join(f"{hours_to_human(hours)} {name}" for name, hours in items)
def build_grouped_report( def build_grouped_report(
issue_hours: List[Tuple[Issue, float]], issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]],
fill_time: bool = True, fill_time: bool = True,
by_activity: bool = False,
) -> List[ReportRow]: ) -> List[ReportRow]:
""" """
Преобразует список задач с затраченным временем в плоский список строк отчёта, Преобразует список задач с затраченным временем в плоский список строк отчёта,
@@ -41,12 +51,20 @@ def build_grouped_report(
prev_project: str = "" prev_project: str = ""
prev_version: str = "" prev_version: str = ""
for issue, hours in issue_hours: for issue, hours, *rest in issue_hours:
activities: Optional[Dict[str, float]] = rest[0] if rest else None
project = str(issue.project) project = str(issue.project)
version = get_version(issue) version = get_version(issue)
status_en = str(issue.status) status_en = str(issue.status)
status_ru = STATUS_TRANSLATION.get(status_en, status_en) status_ru = STATUS_TRANSLATION.get(status_en, status_en)
time_text = hours_to_human(hours) if fill_time else ""
if fill_time:
if by_activity and activities:
time_text = _format_activities(activities)
else:
time_text = hours_to_human(hours)
else:
time_text = ""
display_project = project if project != prev_project else "" display_project = project if project != prev_project else ""
display_version = version if (project != prev_project or version != prev_version) else "" display_version = version if (project != prev_project or version != prev_version) else ""
@@ -64,6 +82,7 @@ def build_grouped_report(
"status_ru": status_ru, "status_ru": status_ru,
"time_text": time_text, "time_text": time_text,
"hours": round(hours, 2), "hours": round(hours, 2),
"activities": activities,
}, },
) )
) )
@@ -74,11 +93,15 @@ def build_grouped_report(
return rows return rows
def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]: def calculate_summary(
"""Возвращает сводку: общее время, время по проектам и версиям.""" rows: List[ReportRow],
by_activity: bool = False,
) -> Dict[str, float]:
"""Возвращает сводку: общее время, время по проектам, версиям и активностям."""
total = 0.0 total = 0.0
by_project: Dict[str, float] = {} by_project: Dict[str, float] = {}
by_project_version: Dict[str, float] = {} by_project_version: Dict[str, float] = {}
by_activity_name: Dict[str, float] = {}
for r in rows: for r in rows:
hours = r.get("hours", 0.0) hours = r.get("hours", 0.0)
@@ -87,11 +110,21 @@ def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]:
key = f"{r['project']}::{r['version']}" key = f"{r['project']}::{r['version']}"
by_project_version[key] = by_project_version.get(key, 0.0) + hours by_project_version[key] = by_project_version.get(key, 0.0) + hours
return { if by_activity:
activities = r.get("activities")
if activities:
for name, act_hours in activities.items():
by_activity_name[name] = by_activity_name.get(name, 0.0) + act_hours
result: Dict[str, float] = {
"total": round(total, 2), "total": round(total, 2),
**{f"project:{k}": round(v, 2) for k, v in by_project.items()}, **{f"project:{k}": round(v, 2) for k, v in by_project.items()},
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()}, **{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
} }
if by_activity:
result.update({f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()})
return result
def group_rows_by_project_and_version( def group_rows_by_project_and_version(

View File

@@ -1,7 +1,7 @@
from typing import TypedDict from typing import Dict, Optional, TypedDict
class ReportRow(TypedDict): class ReportRowBase(TypedDict):
"""Строка итогового отчёта.""" """Строка итогового отчёта."""
project: str project: str
@@ -13,3 +13,9 @@ class ReportRow(TypedDict):
status_ru: str status_ru: str
time_text: str time_text: str
hours: float hours: float
class ReportRow(ReportRowBase, total=False):
"""Строка итогового отчёта с опциональной разбивкой по активностям."""
activities: Optional[Dict[str, float]]

View File

@@ -208,3 +208,96 @@ def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
captured = capsys.readouterr() captured = capsys.readouterr()
assert "Total issues" not in captured.out assert "Total issues" not in captured.out
assert "Total issues" in captured.err assert "Total issues" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_auth_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при ошибке аутентификации."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "Authentication failed" in captured.err
assert "Redmine API error" not in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_forbidden_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при недостаточных правах."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Access denied: no permission")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "Access denied" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при таймауте."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Redmine request timed out after 30 seconds")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "timed out" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
"""CLI --no-time передаётся в файловый форматтер."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xlsx")
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get_formatter:
mock_formatter = mock.MagicMock()
mock_get_formatter.return_value = mock_formatter
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
_, kwargs = mock_get_formatter.call_args
assert kwargs.get("no_time") is True
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_passes_user_id_to_fetch(mock_fetch):
"""CLI --user-id передаётся в fetch_issues_with_spent_time."""
mock_fetch.return_value = None
main(["--date", "2026-01-01--2026-01-31", "--user-id", "42"])
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "42"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_passes_user_login_to_fetch(mock_fetch):
"""CLI --user-login передаётся в fetch_issues_with_spent_time."""
mock_fetch.return_value = None
main(["--date", "2026-01-01--2026-01-31", "--user-login", "ivanov"])
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "ivanov"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_rejects_multiple_user_flags():
"""CLI не принимает одновременно несколько флагов пользователя."""
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--user-id",
"42",
"--user-login",
"ivanov",
]
)
assert code == 1

View File

@@ -1,6 +1,8 @@
import os import os
from unittest import mock from unittest import mock
import pytest
from redmine_reporter.client import fetch_issues_with_spent_time from redmine_reporter.client import fetch_issues_with_spent_time
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
@@ -43,7 +45,7 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
assert result is not None assert result is not None
assert len(result) == 1 assert len(result) == 1
issue, total_hours = result[0] issue, total_hours, _activities = result[0]
assert total_hours == 3.5 assert total_hours == 3.5
@@ -107,7 +109,7 @@ def test_fetch_multiple_issues(mock_redmine_class):
assert result is not None assert result is not None
assert len(result) == 2 assert len(result) == 2
hours_by_id = {issue.id: hours for issue, hours in result} hours_by_id = {issue.id: hours for issue, hours, _ in result}
assert hours_by_id[1] == 1.5 assert hours_by_id[1] == 1.5
assert hours_by_id[2] == 2.0 assert hours_by_id[2] == 2.0
@@ -168,6 +170,191 @@ def test_fetch_uses_custom_verify_path(mock_redmine_class):
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem" assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
"""AuthError превращается в RedmineAPIError с понятным сообщением."""
from redminelib.exceptions import AuthError
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = AuthError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Authentication failed"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_forbidden(mock_redmine_class):
"""ForbiddenError превращается в RedmineAPIError с понятным сообщением."""
from redminelib.exceptions import ForbiddenError
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = ForbiddenError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Access denied"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_timeout(mock_redmine_class):
"""requests Timeout превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = requests.exceptions.Timeout("timeout")
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="timed out"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_connection_error(mock_redmine_class):
"""ConnectionError превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = requests.exceptions.ConnectionError("no route")
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Cannot connect"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_http_500(mock_redmine_class):
"""HTTP 500 превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
response = requests.Response()
response.status_code = 500
mock_redmine.user.get.side_effect = requests.exceptions.HTTPError(
"server error", response=response
)
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="server error"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_specified_user_id(mock_redmine_class):
"""user_id передаётся в time_entry.filter вместо текущего пользователя."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id=42)
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 42
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_resolves_user_login_to_id(mock_redmine_class):
"""Строковый логин разрешается в числовой ID."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_user = mock.MagicMock()
mock_user.id = 7
mock_redmine.user.filter.return_value = [mock_user]
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov")
login_filter_call = mock_redmine.user.filter.call_args_list[0]
assert login_filter_call.kwargs == {"login": "ivanov"}
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 7
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_resolves_user_name_to_id(mock_redmine_class):
"""Строковое имя разрешается в числовой ID, если найден ровно один пользователь."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.user.filter.side_effect = [
[], # поиск по логину не дал результатов
[mock.MagicMock(id=9)], # поиск по имени нашёл одного
]
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="Иванов И.И.")
name_filter_call = mock_redmine.user.filter.call_args_list[1]
assert name_filter_call.kwargs == {"name": "Иванов И.И."}
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 9
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_ambiguous_user_name(mock_redmine_class):
"""Если по имени найдено несколько пользователей — ошибка с понятным сообщением."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.user.filter.side_effect = [
[], # поиск по логину не дал результатов
[mock.MagicMock(id=1), mock.MagicMock(id=2)], # по имени — несколько
]
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Multiple users match"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="Иванов")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_rejects_unknown_user_login(mock_redmine_class):
"""Если пользователь не найден — ошибка с предложением использовать --user-id."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.user.filter.return_value = []
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="not found"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="unknown")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_numeric_string_user_id(mock_redmine_class):
"""Строка из цифр интерпретируется как числовой ID."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="42")
assert mock_redmine.user.filter.call_count == 0
_, kwargs = mock_redmine.time_entry.filter.call_args
assert kwargs["user_id"] == 42
# -- #24: Таймаут и retry -- # -- #24: Таймаут и retry --

View File

@@ -42,6 +42,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Реализовать фичу X", "subject": "Реализовать фичу X",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "4ч 30м", "time_text": "4ч 30м",
"hours": 4.5,
}, },
{ {
"project": "Проект A", "project": "Проект A",
@@ -52,6 +53,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Исправить баг Y", "subject": "Исправить баг Y",
"status_ru": "Решена", "status_ru": "Решена",
"time_text": "", "time_text": "",
"hours": 2.0,
}, },
{ {
"project": "Проект A", "project": "Проект A",
@@ -62,6 +64,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Документация Z", "subject": "Документация Z",
"status_ru": "Ожидание", "status_ru": "Ожидание",
"time_text": "", "time_text": "",
"hours": 1.0,
}, },
{ {
"project": "Проект B", "project": "Проект B",
@@ -72,6 +75,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Обновить README", "subject": "Обновить README",
"status_ru": "Закрыто", "status_ru": "Закрыто",
"time_text": "", "time_text": "",
"hours": 0.0,
}, },
{ {
"project": "Проект C", "project": "Проект C",
@@ -82,6 +86,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Настроить CI", "subject": "Настроить CI",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "3ч 15м", "time_text": "3ч 15м",
"hours": 3.25,
}, },
{ {
"project": "Проект C", "project": "Проект C",
@@ -92,6 +97,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Добавить тесты", "subject": "Добавить тесты",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "", "time_text": "",
"hours": 5.0,
}, },
{ {
"project": "Проект C", "project": "Проект C",
@@ -102,6 +108,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Рефакторинг", "subject": "Рефакторинг",
"status_ru": "Решена", "status_ru": "Решена",
"time_text": "6ч 45м", "time_text": "6ч 45м",
"hours": 6.75,
}, },
] ]
@@ -342,7 +349,73 @@ def test_xlsx_save_creates_valid_file(fake_rows, tmp_path):
assert ws["A1"].value == "Project" assert ws["A1"].value == "Project"
assert ws["A2"].value == "Проект A" assert ws["A2"].value == "Проект A"
assert ws["C2"].value == 101 assert ws["C2"].value == 101
assert ws.max_row == len(fake_rows) + 1 # header + data assert ws["F2"].value == 4.5
assert ws["G2"].value == "4ч 30м"
# header + 7 data rows + 5 version totals + 3 project totals + 1 grand total
assert ws.max_row == 17
def test_xlsx_has_merged_cells(fake_rows, tmp_path):
"""XLSX содержит объединённые ячейки по проектам и версиям."""
from openpyxl import load_workbook
output = tmp_path / "report.xlsx"
XLSXFormatter().save(fake_rows, str(output))
wb = load_workbook(str(output))
ws = wb.active
merged_ranges = [str(r) for r in ws.merged_cells.ranges]
# Проект A: 3 строки данных + 2 итога по версиям + 1 итог по проекту = строки 2-7
assert any("A2:A7" in r for r in merged_ranges)
# Версия v1.0 проекта A: 2 строки данных + 1 итог по версии = строки 2-4
assert any("B2:B4" in r for r in merged_ranges)
def test_xlsx_has_totals(fake_rows, tmp_path):
"""XLSX содержит итоги по версиям, проектам и общий итог."""
from openpyxl import load_workbook
output = tmp_path / "report.xlsx"
XLSXFormatter().save(fake_rows, str(output))
wb = load_workbook(str(output))
ws = wb.active
total_values = [ws.cell(row=r, column=6).value for r in range(2, ws.max_row + 1)]
# Проект A всего: 4.5 + 2 + 1 = 7.5
assert 7.5 in total_values
# Общий итог: 4.5 + 2 + 1 + 0 + 3.25 + 5 + 6.75 = 22.5
assert 22.5 in total_values
# Проверим числовой формат
assert ws["F2"].number_format == "0.00"
def test_xlsx_no_time_keeps_columns_empty_and_skips_totals(fake_rows, tmp_path):
"""XLSX с no_time: колонки времени пустые, итогов нет."""
from openpyxl import load_workbook
output = tmp_path / "report.xlsx"
XLSXFormatter(no_time=True).save(fake_rows, str(output))
wb = load_workbook(str(output))
ws = wb.active
# header + 7 data rows
assert ws.max_row == 8
# Колонки времени пустые для всех строк данных
for row in range(2, ws.max_row + 1):
assert ws.cell(row=row, column=6).value in (None, "")
assert ws.cell(row=row, column=7).value in (None, "")
# Итоговых строк нет
for row in range(2, ws.max_row + 1):
assert not str(ws.cell(row=row, column=1).value or "").startswith("Total")
assert not str(ws.cell(row=row, column=2).value or "").startswith("Total")
# Автофильтр и freeze panes на месте
assert ws.freeze_panes == "A2"
assert ws.auto_filter.ref == "A1:G8"
def test_markdown_formatter_escapes_table_cells(): def test_markdown_formatter_escapes_table_cells():