11 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
86f083aa79 chore(release): bump version to 1.6.0
Bump version from 1.5.0 to 1.6.0 in __init__.py and pyproject.toml.
Update test_cli_version_flag to assert against the current package
version instead of a hardcoded string.
2026-06-29 12:38:54 +07:00
Кокос Артем Николаевич
a82be05b83 fix: handle empty time entries and add missing mypy override for openpyxl
- Treat empty list from fetch_issues_with_spent_time as 'no entries' and exit 0
  instead of crashing later in the pipeline.
- Update CLI output-extension tests to use non-empty mock data so they actually
  reach the extension validation code path.
- Add openpyxl to mypy ignore_missing_imports overrides.

Closes #36
2026-06-29 12:21:44 +07:00
Кокос Артем Николаевич
ca89832d74 feat: JSON, Excel export and time summary
- #23: add JSONFormatter and XLSXFormatter
- add openpyxl dependency for .xlsx export
- #22: add --summary flag and calculate_summary() in report_builder
- ReportRow now carries raw hours for summary calculations
- update CLI help and README with .json/.xlsx formats and --summary
- add tests for new formatters and summary computation

Closes #22, closes #23
2026-06-29 12:09:58 +07:00
Кокос Артем Николаевич
f6afc4096d feat(cli): dynamic default range, --version, --verbose, --debug, --url, --api-key, --config
- #17: default date range falls back to current month when .env dates are missing
- #18: add --version, --verbose, --debug flags
- #20: add --url, --api-key, --config CLI overrides
- Config supports CLI overrides for URL/API key and explicit config file loading
- Update README with new CLI options
- Add tests for new flags and config overrides

Closes #17, closes #18, closes #20
2026-06-29 12:02:51 +07:00
Кокос Артем Николаевич
738d9d543e fix: mount retry adapter on Redmine engine session
python-redmine exposes  as a context manager, while the
actual requests.Session lives in . Mounting the
retry adapter on the wrong object caused:

    'function' object has no attribute 'mount'

on startup. Update the related test to mock the real session.
2026-06-29 11:53:09 +07:00
Артём Кокос
58fa5a7ab4 Add retry/backoff, timeout, and issue_id chunking to Redmine client
- #24: Mount HTTPAdapter with Retry(total=3, backoff_factor=0.5) for
  HTTP 429/500/502/503/504 on the Redmine session, set 30s timeout
- #21: Split large issue_id lists into chunks of 100 to avoid exceeding
  URL length limits on reverse proxies

Closes #24, #21
2026-06-27 13:06:41 +07:00
Артём Кокос
14219564dd Fix 9 bugs: ODT covered-cells, CSV BOM, HTML charset, stderr, dead code
- #13 (critical): Add CoveredTableCell elements to ODT for valid row spans (ODF 1.2)
- #28: Move "Total issues" info message from stdout to stderr (clean pipe output)
- #27: Wrap HTML export in full document with DOCTYPE and meta charset utf-8
- #26: Save CSV with utf-8-sig encoding (UTF-8 BOM for Excel compatibility)
- #31: Document CSV uses full project/version values (not display_* like console/MD)
- #30: Fix ODT header formatting when author is empty (no leading dot/space)
- #36: Remove test_cli_smoke_empty testing unreachable code path (return [])
- #37: Remove unused mock_path variable in ODT test fixture
- #34: Remove unreachable len(parts) != 2 check in parse_date_range

Closes #13, #28, #27, #26, #31, #30, #36, #37, #34
2026-06-27 13:01:32 +07:00
Артём Кокос
da069993b9 Extract grouping logic into shared function (#19)
Add group_rows_by_project_and_version() to report_builder.py and replace
the duplicated inline grouping in both html.py and odt.py with a single
call to the shared function. This ensures consistent grouping behavior
across formatters and removes a maintenance burden — any change to
grouping logic now happens in one place.

Closes #19
2026-06-26 00:21:58 +07:00
Артём Кокос
dbc4cf960a Fix version display, deterministic sort, remove duplicate sort
get_version() now returns version.name instead of str(version), which
returned the numeric ID from redminelib.resources.Version. Falls back
to str() when .name is absent (#14).

Add issue.id as tertiary sort key in build_grouped_report so tasks
within the same project+version group always appear in the same order
regardless of API response ordering (#33).

Remove redundant sort from client.fetch_issues_with_spent_time — the
sort already runs in report_builder.build_grouped_report, so doing it
twice wastes CPU. Also remove the now-unused get_version import (#32).

Closes #14, closes #32, closes #33
2026-06-25 23:32:14 +07:00
Артём Кокос
3a6d1b7ba7 Fix .env override priority and password whitespace stripping
Change load_dotenv() to load_dotenv(override=False) so real environment
variables always take priority over stale .env files. This prevents a
hardcoded or outdated REDMINE_API_KEY in .env from silently overriding
a fresh token exported in the shell (#15).

Add .strip() to get_redmine_password() for consistency with all other
config getters — trailing whitespace in .env no longer causes silent
auth failures (#35).

Closes #15, closes #35
2026-06-25 21:06:19 +07:00
Артём Кокос
3956decd4e Fix package structure and lazy-import ODT formatter
Add missing __init__.py to formatters/ so setuptools.find_packages
includes the subpackage in wheel/sdist builds (#16).

Move ODTFormatter import from top-level to lazy import inside
get_formatter_by_extension() so missing odfpy no longer crashes module
load before main() runs. Remove dead except ImportError handler in
cli.py save() block; surface a clear 'odfpy is not installed' message
when the formatter factory returns None for .odt (#25).

Closes #16, closes #25
2026-06-25 20:42:13 +07:00
22 changed files with 1088 additions and 108 deletions

View File

@@ -14,9 +14,13 @@ CLI-инструмент для генерации отчётов по зада
- Группировка задач по проекту и версии. - Группировка задач по проекту и версии.
- Перевод статусов задач на русский язык. - Перевод статусов задач на русский язык.
- Вывод в консоль в табличном или компактном виде. - Вывод в консоль в табличном или компактном виде.
- Экспорт в ODT, CSV, Markdown и HTML. - Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
- Сводка по затраченному времени (итоги и разбивка по проектам/версиям).
- Автоматическое определение месяца ODT-отчёта по конечной дате периода. - Автоматическое определение месяца ODT-отчёта по конечной дате периода.
- Настройка периода отчёта по умолчанию через `.env`. - Настройка периода отчёта по умолчанию через `.env` (или автоматически — текущий месяц).
- Переопределение URL и API-ключа через CLI.
- Загрузка альтернативного `.env` через `--config`.
- Загрузка альтернативного `.env` через `--config`.
## Установка ## Установка
@@ -44,7 +48,7 @@ DEFAULT_FROM_DATE=2026-01-01
DEFAULT_TO_DATE=2026-01-31 DEFAULT_TO_DATE=2026-01-31
``` ```
Если задан `REDMINE_API_KEY`, он используется в первую очередь. Если задан `REDMINE_API_KEY`, он используется в первую очередь. Значения из `.env` можно переопределить через CLI: `--url`, `--api-key`, `--author`, а также загрузить другой файл конфигурации через `--config`.
Резервный вариант авторизации: Резервный вариант авторизации:
@@ -86,7 +90,7 @@ DEFAULT_TO_DATE=2026-01-31
source .venv/bin/activate source .venv/bin/activate
``` ```
Отчёт за период по умолчанию: Отчёт за период по умолчанию (текущий месяц или из `.env`):
```bash ```bash
redmine-reporter redmine-reporter
@@ -100,12 +104,37 @@ redmine-reporter --date 2026-02-01--2026-02-28
Период должен быть задан в формате `YYYY-MM-DD--YYYY-MM-DD`. Начальная дата не может быть позже конечной. Период должен быть задан в формате `YYYY-MM-DD--YYYY-MM-DD`. Начальная дата не может быть позже конечной.
Переопределение URL и API-ключа из `.env`:
```bash
redmine-reporter --url https://red.example.com --api-key ваш_токен
```
Использование альтернативного конфигурационного файла:
```bash
redmine-reporter --config /path/to/.env
```
Компактный вывод: Компактный вывод:
```bash ```bash
redmine-reporter --compact redmine-reporter --compact
``` ```
Подробный или отладочный вывод:
```bash
redmine-reporter --verbose
redmine-reporter --debug
```
Вывод версии:
```bash
redmine-reporter --version
```
Экспорт: Экспорт:
```bash ```bash
@@ -113,8 +142,31 @@ redmine-reporter --output report.odt
redmine-reporter --output report.csv redmine-reporter --output report.csv
redmine-reporter --output report.md redmine-reporter --output report.md
redmine-reporter --output report.html redmine-reporter --output report.html
redmine-reporter --output report.json
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-отчёт: ODT-отчёт:
- месяц в заголовке определяется по `to_date`; - месяц в заголовке определяется по `to_date`;
@@ -127,6 +179,12 @@ ODT-отчёт:
redmine-reporter --no-time redmine-reporter --no-time
``` ```
Сводка по времени (итоги и разбивка по проектам):
```bash
redmine-reporter --summary
```
## Разработка ## Разработка
Установка зависимостей для разработки: Установка зависимостей для разработки:

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "redmine-reporter" name = "redmine-reporter"
version = "1.5.0" version = "1.6.0"
description = "Redmine time-entry based issue reporter for internal use" description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md" readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
@@ -23,6 +23,7 @@ dependencies = [
"tabulate>=0.9.0", "tabulate>=0.9.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"odfpy>=1.4.0", "odfpy>=1.4.0",
"openpyxl>=3.1.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -56,5 +57,5 @@ multi_line_output = 3
warn_unused_configs = true warn_unused_configs = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["odf.*", "redminelib.*", "tabulate"] module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*"]
ignore_missing_imports = true ignore_missing_imports = true

View File

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

View File

@@ -1,22 +1,22 @@
import argparse import argparse
import logging
import os import os
import re import re
import sys import sys
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from . import __version__
from .client import fetch_issues_with_spent_time from .client import 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 from .report_builder import build_grouped_report, calculate_summary
def parse_date_range(date_arg: str) -> tuple[str, str]: def parse_date_range(date_arg: str) -> tuple[str, str]:
if "--" not in date_arg: if "--" not in date_arg:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
parts = date_arg.split("--", 1) parts = date_arg.split("--", 1)
if len(parts) != 2:
raise ValueError("Invalid date range format")
from_date, to_date = parts[0].strip(), parts[1].strip() from_date, to_date = parts[0].strip(), parts[1].strip()
date_pattern = r"\d{4}-\d{2}-\d{2}" date_pattern = r"\d{4}-\d{2}-\d{2}"
@@ -43,8 +43,7 @@ def main(argv: Optional[List[str]] = None) -> int:
parser.add_argument( parser.add_argument(
"--date", "--date",
default=Config.get_default_date_range(), default=Config.get_default_date_range(),
# help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: %(default)s)" help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: current month or from .env)",
help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default from .env or %(default)s)",
) )
parser.add_argument( parser.add_argument(
"--compact", "--compact",
@@ -53,7 +52,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
parser.add_argument( parser.add_argument(
"--output", "--output",
help="Path to output file (.odt, .csv, .md, .html). If omitted, prints to stdout.", help="Path to output file (.odt, .csv, .md, .html, .json, .xlsx). If omitted, prints to stdout.",
) )
parser.add_argument( parser.add_argument(
"--author", default="", help="Override author name from .env (REDMINE_AUTHOR)" "--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"
@@ -61,8 +60,38 @@ def main(argv: Optional[List[str]] = None) -> int:
parser.add_argument( parser.add_argument(
"--no-time", action="store_true", help="Do not include spent time into table" "--no-time", action="store_true", help="Do not include spent time into table"
) )
parser.add_argument("--url", help="Override Redmine URL from .env (REDMINE_URL)")
parser.add_argument("--api-key", help="Override Redmine API key from .env (REDMINE_API_KEY)")
parser.add_argument("--config", help="Path to .env config file")
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
parser.add_argument("--debug", action="store_true", help="Enable debug output")
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {__version__}",
help="Show version and exit",
)
parser.add_argument(
"--summary",
action="store_true",
help="Print summary (total hours by project/version) to stderr",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
# CLI-переопределения имеют приоритет над .env/env.
if args.config:
Config.load_config(args.config)
Config.set_redmine_url(args.url)
Config.set_redmine_api_key(args.api_key)
# Настройка уровня логирования
if args.debug:
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
elif args.verbose:
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
else:
logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
try: try:
Config.validate() Config.validate()
except ValueError as e: except ValueError as e:
@@ -85,16 +114,28 @@ def main(argv: Optional[List[str]] = None) -> int:
print(" No time entries found in the given period.", file=sys.stderr) print(" No time entries found in the given period.", file=sys.stderr)
return 0 return 0
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]") if not issue_hours:
print(" No time entries found in the given period.", file=sys.stderr)
return 0
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)
if args.summary:
summary = calculate_summary(rows)
print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr)
for key, value in summary.items():
if key.startswith("project:"):
project = key.split(":", 1)[1]
print(f" {project}: {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()
if not output_ext: if not output_ext:
print( print(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md или .html", "❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1
@@ -107,25 +148,23 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
if not formatter: if not formatter:
known_exts = ", ".join([".odt", ".csv", ".md", ".html"])
print(
f"❌ Неизвестный формат файла: {output_ext!r}. Поддерживаются: {known_exts}",
file=sys.stderr,
)
return 1
try:
formatter.save(rows, args.output)
print(f"✅ Report saved to {args.output}")
except ImportError as e:
if output_ext == ".odt": if output_ext == ".odt":
print( print(
"❌ odfpy is not installed. Install with: pip install odfpy", "❌ odfpy is not installed. Install with: pip install odfpy",
file=sys.stderr, file=sys.stderr,
) )
else: else:
print(f"❌ Import error: {e}", file=sys.stderr) known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
print(
f"❌ Неизвестный формат файла: {output_ext!r}. "
f"Поддерживаются: {known_exts}",
file=sys.stderr,
)
return 1 return 1
try:
formatter.save(rows, args.output)
print(f"✅ Report saved to {args.output}")
except Exception as e: except Exception as e:
fmt = output_ext.lstrip(".").upper() fmt = output_ext.lstrip(".").upper()
print(f"{fmt} export error: {e}", file=sys.stderr) print(f"{fmt} export error: {e}", file=sys.stderr)

View File

@@ -1,10 +1,17 @@
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import requests
from redminelib import Redmine from redminelib import Redmine
from redminelib.resources import Issue from redminelib.resources import Issue
from urllib3.util.retry import Retry
from .config import Config from .config import Config
from .utils import get_version
# Таймаут на один HTTP-запрос к Redmine (секунды).
REQUEST_TIMEOUT = 30
# Размер чанка для запроса задач по issue_id, чтобы не превышать лимит длины URL (#21).
ISSUE_ID_CHUNK_SIZE = 100
def _get_redmine_auth_kwargs() -> Dict[str, Any]: def _get_redmine_auth_kwargs() -> Dict[str, Any]:
@@ -18,6 +25,48 @@ def _get_redmine_auth_kwargs() -> Dict[str, Any]:
} }
def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
"""Создаёт HTTPAdapter с retry для временных ошибок (#24)."""
retry = Retry(
total=3,
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD", "OPTIONS"],
)
return requests.adapters.HTTPAdapter(max_retries=retry)
def _create_redmine() -> Redmine:
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24)."""
redmine = Redmine(
Config.get_redmine_url(),
**_get_redmine_auth_kwargs(),
requests={
"verify": Config.get_redmine_verify(),
"timeout": REQUEST_TIMEOUT,
},
)
# Монтируем retry-адаптер на сессию для автоматических повторов.
# В python-redmine сессия живёт в engine, а redmine.session — контекстный менеджер.
retry_adapter = _make_retry_adapter()
redmine.engine.session.mount("https://", retry_adapter)
redmine.engine.session.mount("http://", retry_adapter)
return redmine
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = []
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
issue_list_str = ",".join(str(x) for x in chunk)
issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc")
all_issues.extend(issues)
return all_issues
def fetch_issues_with_spent_time( def fetch_issues_with_spent_time(
from_date: str, to_date: str from_date: str, to_date: str
) -> Optional[List[Tuple[Issue, float]]]: ) -> Optional[List[Tuple[Issue, float]]]:
@@ -27,11 +76,7 @@ def fetch_issues_with_spent_time(
Returns list of (issue, total_hours) tuples. Returns list of (issue, total_hours) tuples.
""" """
redmine = Redmine( redmine = _create_redmine()
Config.get_redmine_url(),
**_get_redmine_auth_kwargs(),
requests={"verify": Config.get_redmine_verify()},
)
current_user = redmine.user.get("current") current_user = redmine.user.get("current")
time_entries = redmine.time_entry.filter( time_entries = redmine.time_entry.filter(
@@ -50,17 +95,16 @@ def fetch_issues_with_spent_time(
if not issue_ids: if not issue_ids:
return None return None
# Загружаем полные объекты задач # Загружаем полные объекты задач чанками (#21)
issue_list_str = ",".join(str(i) for i in issue_ids) sorted_ids = sorted(issue_ids)
issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc") issues = _fetch_issues_chunked(redmine, sorted_ids)
# Сопоставляем задачи с суммарным временем # Сопоставляем задачи с суммарным временем.
# Сортировка выполняется в report_builder.build_grouped_report,
# здесь оставляем порядок API как есть.
result = [] result = []
for issue in issues: for issue in issues:
total_hours = spent_time.get(issue.id, 0.0) total_hours = spent_time.get(issue.id, 0.0)
result.append((issue, total_hours)) result.append((issue, total_hours))
# Сортируем по (проект, версия)
result.sort(key=lambda x: (str(x[0].project), get_version(x[0])))
return result return result

View File

@@ -1,9 +1,10 @@
import os import os
from datetime import date, timedelta
from typing import Union from typing import Union
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv(override=False)
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt" DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
FALSE_VALUES = {"0", "false", "no", "off"} FALSE_VALUES = {"0", "false", "no", "off"}
@@ -11,12 +12,32 @@ TRUE_VALUES = {"1", "true", "yes", "on"}
class Config: class Config:
_cli_url: str | None = None
_cli_api_key: str | None = None
@classmethod
def load_config(cls, path: str) -> None:
"""Загружает переменные из указанного .env-файла с override."""
load_dotenv(path, override=True)
@classmethod
def set_redmine_url(cls, url: str) -> None:
cls._cli_url = url.strip().rstrip("/") if url else None
@classmethod
def set_redmine_api_key(cls, key: str) -> None:
cls._cli_api_key = key.strip() if key else None
@classmethod @classmethod
def get_redmine_url(cls) -> str: def get_redmine_url(cls) -> str:
if cls._cli_url is not None:
return cls._cli_url
return os.getenv("REDMINE_URL", "").strip().rstrip("/") return os.getenv("REDMINE_URL", "").strip().rstrip("/")
@classmethod @classmethod
def get_redmine_api_key(cls) -> str: def get_redmine_api_key(cls) -> str:
if cls._cli_api_key is not None:
return cls._cli_api_key
return os.getenv("REDMINE_API_KEY", "").strip() return os.getenv("REDMINE_API_KEY", "").strip()
@classmethod @classmethod
@@ -25,7 +46,7 @@ class Config:
@classmethod @classmethod
def get_redmine_password(cls) -> str: def get_redmine_password(cls) -> str:
return os.getenv("REDMINE_PASSWORD", "") return os.getenv("REDMINE_PASSWORD", "").strip()
@classmethod @classmethod
def get_redmine_verify(cls) -> Union[bool, str]: def get_redmine_verify(cls) -> Union[bool, str]:
@@ -53,8 +74,17 @@ class Config:
default_to_date = os.getenv("DEFAULT_TO_DATE", "").strip() default_to_date = os.getenv("DEFAULT_TO_DATE", "").strip()
if default_from_date and default_to_date: if default_from_date and default_to_date:
return f"{default_from_date}--{default_to_date}" return f"{default_from_date}--{default_to_date}"
# fallback hardcoded
return "2025-12-19--2026-01-31" # fallback: текущий месяц
today = date.today()
start = today.replace(day=1)
# последний день месяца: берём первое число следующего месяца и вычитаем день
if today.month == 12:
next_month = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month = today.replace(month=today.month + 1, day=1)
end = next_month - timedelta(days=1)
return f"{start.isoformat()}--{end.isoformat()}"
@classmethod @classmethod
def validate(cls) -> None: def validate(cls) -> None:

View File

View File

@@ -7,7 +7,13 @@ from .base import Formatter
class CSVFormatter(Formatter): class CSVFormatter(Formatter):
"""Форматтер для экспорта в CSV.""" """Форматтер для экспорта в CSV.
Использует полные значения project/version (а не display-значения с пустыми
ячейками для групп). Каждая строка CSV самодостаточна — это корректно для
табличного формата (#31). Файл сохраняется в UTF-8 с BOM (utf-8-sig) для
корректного отображения кириллицы в Microsoft Excel (#26).
"""
def __init__(self, **_kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
@@ -31,5 +37,5 @@ class CSVFormatter(Formatter):
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: List[ReportRow], output_path: str) -> None:
content = self.format(rows) content = self.format(rows)
with open(output_path, "w", encoding="utf-8", newline="") as f: with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
f.write(content) f.write(content)

View File

@@ -4,15 +4,19 @@ from .base import Formatter
from .console import CompactFormatter, TableFormatter from .console import CompactFormatter, TableFormatter
from .csv import CSVFormatter from .csv import CSVFormatter
from .html import HTMLFormatter from .html import HTMLFormatter
from .json import JSONFormatter
from .markdown import MarkdownFormatter from .markdown import MarkdownFormatter
from .odt import ODTFormatter from .xlsx import XLSXFormatter
# Словарь для сопоставления расширений файлов с классами форматтеров # Словарь для сопоставления расширений файлов с классами форматтеров.
# ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
# odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
FORMATTER_MAP: Dict[str, Type[Formatter]] = { FORMATTER_MAP: Dict[str, Type[Formatter]] = {
".odt": ODTFormatter,
".csv": CSVFormatter, ".csv": CSVFormatter,
".json": JSONFormatter,
".md": MarkdownFormatter, ".md": MarkdownFormatter,
".html": HTMLFormatter, ".html": HTMLFormatter,
".xlsx": XLSXFormatter,
} }
@@ -27,10 +31,23 @@ def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]:
""" """
Возвращает экземпляр форматтера по расширению файла. Возвращает экземпляр форматтера по расширению файла.
Ключевые аргументы (**kwargs) передаются в конструктор форматтера. Ключевые аргументы (**kwargs) передаются в конструктор форматтера.
Возвращает None для .odt, если odfpy не установлен.
""" """
formatter_class = FORMATTER_MAP.get(extension.lower()) ext = extension.lower()
formatter_class = FORMATTER_MAP.get(ext)
if formatter_class: if formatter_class:
return formatter_class(**kwargs) return formatter_class(**kwargs)
# ODT требует odfpy — ленивый импорт, чтобы отсутствие зависимости
# не ломало загрузку модуля и другие форматтеры.
if ext == ".odt":
try:
from .odt import ODTFormatter
except ImportError:
return None
return ODTFormatter(**kwargs)
return None return None

View File

@@ -1,6 +1,7 @@
from html import escape from html import escape
from typing import Dict, List from typing import List
from ..report_builder import group_rows_by_project_and_version
from ..types import ReportRow from ..types import ReportRow
from .base import Formatter from .base import Formatter
@@ -12,18 +13,15 @@ class HTMLFormatter(Formatter):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
# Сгруппируем данные projects = group_rows_by_project_and_version(rows)
projects: Dict[str, Dict[str, List[ReportRow]]] = {}
for r in rows:
proj = r["project"]
ver = r["version"]
if proj not in projects:
projects[proj] = {}
if ver not in projects[proj]:
projects[proj][ver] = []
projects[proj][ver].append(r)
lines = [ lines = [
"<!DOCTYPE html>",
'<html lang="ru">',
"<head>",
' <meta charset="utf-8">',
"</head>",
"<body>",
'<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse; font-family: Arial, sans-serif;">', '<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse; font-family: Arial, sans-serif;">',
" <thead>", " <thead>",
" <tr>", " <tr>",
@@ -77,6 +75,8 @@ class HTMLFormatter(Formatter):
lines.append(" </tbody>") lines.append(" </tbody>")
lines.append("</table>") lines.append("</table>")
lines.append("</body>")
lines.append("</html>")
return "\n".join(lines) return "\n".join(lines)
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: List[ReportRow], output_path: str) -> None:

View File

@@ -0,0 +1,31 @@
import json
from typing import List
from ..types import ReportRow
from .base import Formatter
class JSONFormatter(Formatter):
"""Форматтер для экспорта отчёта в JSON."""
def __init__(self, **_kwargs):
super().__init__()
def format(self, rows: List[ReportRow]) -> str:
data = [
{
"project": r["project"],
"version": r["version"],
"issue_id": r["issue_id"],
"subject": r["subject"],
"status": r["status_ru"],
"time": r["time_text"],
}
for r in rows
]
return json.dumps(data, ensure_ascii=False, indent=2)
def save(self, rows: List[ReportRow], output_path: str) -> None:
content = self.format(rows)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)

View File

@@ -1,11 +1,12 @@
from importlib import resources from importlib import resources
from typing import Dict, List from typing import List
from odf.opendocument import OpenDocument, load from odf.opendocument import OpenDocument, load
from odf.style import Style, TableCellProperties, TableColumnProperties from odf.style import Style, TableCellProperties, TableColumnProperties
from odf.table import Table, TableCell, TableColumn, TableRow from odf.table import CoveredTableCell, Table, TableCell, TableColumn, TableRow
from odf.text import P from odf.text import P
from ..report_builder import group_rows_by_project_and_version
from ..types import ReportRow from ..types import ReportRow
from ..utils import get_month_name_from_range from ..utils import get_month_name_from_range
from .base import Formatter from .base import Formatter
@@ -34,7 +35,10 @@ class ODTFormatter(Formatter):
# Заголовок # Заголовок
month_name = get_month_name_from_range(self.from_date, self.to_date) month_name = get_month_name_from_range(self.from_date, self.to_date)
header_text = f"{self.author}. Отчет за месяц {month_name}." if self.author:
header_text = f"{self.author}. Отчет за месяц {month_name}."
else:
header_text = f"Отчет за месяц {month_name}."
doc.text.addElement(P(stylename=para_style_name, text=header_text)) doc.text.addElement(P(stylename=para_style_name, text=header_text))
doc.text.addElement(P(stylename=para_style_name, text="")) doc.text.addElement(P(stylename=para_style_name, text=""))
@@ -69,18 +73,7 @@ class ODTFormatter(Formatter):
header_row.addElement(cell) header_row.addElement(cell)
table.addElement(header_row) table.addElement(header_row)
projects: Dict[str, Dict[str, List[ReportRow]]] = {} projects = group_rows_by_project_and_version(rows)
for r in rows:
project = r["project"]
version = r["version"]
if project not in projects:
projects[project] = {}
if version not in projects[project]:
projects[project][version] = []
projects[project][version].append(r)
# Данные с двухуровневой группировкой и объединением ячеек # Данные с двухуровневой группировкой и объединением ячеек
for project, versions in projects.items(): for project, versions in projects.items():
@@ -96,15 +89,19 @@ class ODTFormatter(Formatter):
for r in rows_for_version: for r in rows_for_version:
row = TableRow() row = TableRow()
# Ячейка "Проект" - только в первой строке всего проекта # Ячейка "Проект" - только в первой строке всего проекта,
# в остальных — covered-cell для валидности ODF (#13)
if first_version_in_project and first_row_in_version: if first_version_in_project and first_row_in_version:
cell_project = TableCell(stylename=cell_style_name) cell_project = TableCell(stylename=cell_style_name)
cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) cell_project.setAttribute("numberrowsspanned", str(total_project_rows))
p = P(stylename=para_style_name, text=project) p = P(stylename=para_style_name, text=project)
cell_project.addElement(p) cell_project.addElement(p)
row.addElement(cell_project) row.addElement(cell_project)
else:
row.addElement(CoveredTableCell())
# Ячейка "Версия" - только в первой строке каждой версии # Ячейка "Версия" - только в первой строке каждой версии,
# в остальных — covered-cell для валидности ODF (#13)
if first_row_in_version: if first_row_in_version:
cell_version = TableCell(stylename=cell_style_name) cell_version = TableCell(stylename=cell_style_name)
cell_version.setAttribute("numberrowsspanned", str(row_span_version)) cell_version.setAttribute("numberrowsspanned", str(row_span_version))
@@ -112,6 +109,8 @@ class ODTFormatter(Formatter):
cell_version.addElement(p) cell_version.addElement(p)
row.addElement(cell_version) row.addElement(cell_version)
first_row_in_version = False first_row_in_version = False
else:
row.addElement(CoveredTableCell())
# Остальные колонки # Остальные колонки
task_cell = TableCell(stylename=cell_style_name) task_cell = TableCell(stylename=cell_style_name)

View File

@@ -0,0 +1,42 @@
from typing import List
from openpyxl import Workbook
from openpyxl.styles import Font
from ..types import ReportRow
from .base import Formatter
class XLSXFormatter(Formatter):
"""Форматтер для экспорта отчёта в Excel (.xlsx)."""
def __init__(self, **_kwargs):
super().__init__()
def format(self, rows: List[ReportRow]) -> Workbook:
wb = Workbook()
ws = wb.active
ws.title = "Report"
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]
ws.append(headers)
for cell in ws[1]:
cell.font = Font(bold=True)
for r in rows:
ws.append(
[
r["project"],
r["version"],
r["issue_id"],
r["subject"],
r["status_ru"],
r["time_text"],
]
)
return wb
def save(self, rows: List[ReportRow], output_path: str) -> None:
wb = self.format(rows)
wb.save(output_path)

View File

@@ -1,4 +1,4 @@
from typing import List, Tuple, cast from typing import Dict, List, Tuple, cast
from redminelib.resources import Issue from redminelib.resources import Issue
@@ -35,7 +35,7 @@ def build_grouped_report(
""" """
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе # Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]))) issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id))
rows: List[ReportRow] = [] rows: List[ReportRow] = []
prev_project: str = "" prev_project: str = ""
@@ -63,6 +63,7 @@ def build_grouped_report(
"subject": issue.subject, "subject": issue.subject,
"status_ru": status_ru, "status_ru": status_ru,
"time_text": time_text, "time_text": time_text,
"hours": round(hours, 2),
}, },
) )
) )
@@ -71,3 +72,48 @@ def build_grouped_report(
prev_version = version prev_version = version
return rows return rows
def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]:
"""Возвращает сводку: общее время, время по проектам и версиям."""
total = 0.0
by_project: Dict[str, float] = {}
by_project_version: Dict[str, float] = {}
for r in rows:
hours = r.get("hours", 0.0)
total += hours
by_project[r["project"]] = by_project.get(r["project"], 0.0) + hours
key = f"{r['project']}::{r['version']}"
by_project_version[key] = by_project_version.get(key, 0.0) + hours
return {
"total": round(total, 2),
**{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()},
}
def group_rows_by_project_and_version(
rows: List[ReportRow],
) -> Dict[str, Dict[str, List[ReportRow]]]:
"""
Группирует плоский список строк отчёта в иерархию project → version → [rows].
Предполагается, что rows уже отсортирован (build_grouped_report это гарантирует).
Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+).
Используется форматтерами HTML и ODT для объединения ячеек.
"""
projects: Dict[str, Dict[str, List[ReportRow]]] = {}
for r in rows:
project = r["project"]
version = r["version"]
if project not in projects:
projects[project] = {}
if version not in projects[project]:
projects[project][version] = []
projects[project][version].append(r)
return projects

View File

@@ -12,3 +12,4 @@ class ReportRow(TypedDict):
subject: str subject: str
status_ru: str status_ru: str
time_text: str time_text: str
hours: float

View File

@@ -33,7 +33,8 @@ def get_version(issue) -> str:
version = getattr(issue, "fixed_version", None) version = getattr(issue, "fixed_version", None)
if version is None: if version is None:
return "<N/A>" return "<N/A>"
return str(version) name = getattr(version, "name", None)
return str(name) if name else str(version)
def hours_to_human(hours: float) -> str: def hours_to_human(hours: float) -> str:

View File

@@ -1,6 +1,4 @@
import os import os
import sys
from io import StringIO
from unittest import mock from unittest import mock
import pytest import pytest
@@ -13,6 +11,18 @@ VALID_ENV = {
} }
@pytest.fixture(autouse=True)
def _reset_config_overrides():
"""Сбрасывает CLI-переопределения Config перед каждым тестом."""
from redmine_reporter.config import Config
Config.set_redmine_url("")
Config.set_redmine_api_key("")
yield
Config.set_redmine_url("")
Config.set_redmine_api_key("")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"date_arg, expected", "date_arg, expected",
[ [
@@ -38,21 +48,6 @@ def test_parse_date_range_invalid(date_arg):
parse_date_range(date_arg) parse_date_range(date_arg)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_smoke_empty(mock_fetch):
"""Пустой список задач -- выход 0, сообщение о 0 задачах."""
mock_fetch.return_value = []
captured = StringIO()
old_stdout, sys.stdout = sys.stdout, captured
try:
code = main(["--date", "2026-01-01--2026-01-31"])
finally:
sys.stdout = old_stdout
assert code == 0
assert "Total issues: 0" in captured.getvalue()
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_returns_zero_on_no_entries(mock_fetch): def test_cli_returns_zero_on_no_entries(mock_fetch):
@@ -76,11 +71,23 @@ def test_cli_invalid_date_format():
assert code == 1 assert code == 1
class _MockIssue:
"""Простой mock Redmine Issue для CLI-тестов."""
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
self.id = issue_id
self.subject = subject
self.project = project
self.status = status
self.fixed_version = None
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_unknown_output_extension(mock_fetch, tmp_path): def test_cli_unknown_output_extension(mock_fetch, tmp_path):
"""Неизвестное расширение файла -- выход 1.""" """Неизвестное расширение файла -- выход 1."""
mock_fetch.return_value = [] issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xyz") output = str(tmp_path / "report.xyz")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output]) code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1 assert code == 1
@@ -90,7 +97,114 @@ def test_cli_unknown_output_extension(mock_fetch, tmp_path):
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_output_without_extension(mock_fetch, tmp_path): def test_cli_output_without_extension(mock_fetch, tmp_path):
"""Файл без расширения -- выход 1 с подсказкой.""" """Файл без расширения -- выход 1 с подсказкой."""
mock_fetch.return_value = [] issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report") output = str(tmp_path / "report")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output]) code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1 assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
"""При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_gf.return_value = None
output = str(tmp_path / "report.odt")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1
captured = capsys.readouterr()
assert "odfpy" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_version_flag(capsys):
"""--version выводит версию и завершается с 0."""
with pytest.raises(SystemExit) as exc_info:
main(["--version"])
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "redmine-reporter" in captured.out
from redmine_reporter import __version__
assert __version__ in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_verbose_and_debug_flags_accepted(mock_fetch):
"""--verbose и --debug не ломают запуск."""
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31", "--verbose"])
assert code == 0
code = main(["--date", "2026-01-01--2026-01-31", "--debug"])
assert code == 0
class _MockIssue:
"""Простой mock Redmine Issue для CLI-тестов."""
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
self.id = issue_id
self.subject = subject
self.project = project
self.status = status
self.fixed_version = None
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_url_and_api_key_override_env(mock_fetch):
"""--url и --api-key переопределяют отсутствующие/другие env-переменные."""
mock_fetch.return_value = None
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--url",
"https://other.redmine.loc",
"--api-key",
"cli-token",
]
)
assert code == 0
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_config_file_loading(mock_fetch, tmp_path):
"""--config загружает переменные из указанного .env-файла."""
config_path = tmp_path / "custom.env"
config_path.write_text(
"REDMINE_URL=https://config.redmine.loc\n" "REDMINE_API_KEY=config-token\n",
encoding="utf-8",
)
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31", "--config", 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")
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
"""--summary выводит общее время и разбивку по проектам в stderr."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
main(["--date", "2026-01-01--2026-01-31", "--summary"])
captured = capsys.readouterr()
assert "Total time" in captured.err
assert "Project" in captured.err or "Project" in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert "Total issues" not in captured.out
assert "Total issues" in captured.err

View File

@@ -133,7 +133,7 @@ def test_fetch_uses_api_key_when_present(mock_redmine_class):
_, kwargs = mock_redmine_class.call_args _, kwargs = mock_redmine_class.call_args
assert kwargs["key"] == "api-token" assert kwargs["key"] == "api-token"
assert kwargs["requests"] == {"verify": DEFAULT_REDMINE_VERIFY} assert kwargs["requests"]["verify"] == DEFAULT_REDMINE_VERIFY
assert "username" not in kwargs assert "username" not in kwargs
assert "password" not in kwargs assert "password" not in kwargs
@@ -151,7 +151,7 @@ def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
_, kwargs = mock_redmine_class.call_args _, kwargs = mock_redmine_class.call_args
assert kwargs["username"] == "user" assert kwargs["username"] == "user"
assert kwargs["password"] == "password" assert kwargs["password"] == "password"
assert kwargs["requests"] == {"verify": DEFAULT_REDMINE_VERIFY} assert kwargs["requests"]["verify"] == DEFAULT_REDMINE_VERIFY
assert "key" not in kwargs assert "key" not in kwargs
@@ -165,4 +165,86 @@ def test_fetch_uses_custom_verify_path(mock_redmine_class):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31") fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
_, kwargs = mock_redmine_class.call_args _, kwargs = mock_redmine_class.call_args
assert kwargs["requests"] == {"verify": "/tmp/redmine-ca.pem"} assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
# -- #24: Таймаут и retry --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_sets_timeout_in_requests(mock_redmine_class):
"""В requests dict передаётся timeout (#24)."""
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")
_, kwargs = mock_redmine_class.call_args
assert "timeout" in kwargs["requests"]
assert kwargs["requests"]["timeout"] == 30
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_mounts_retry_adapter(mock_redmine_class):
"""На сессию монтируется HTTPAdapter с retry для временных ошибок (#24)."""
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")
# Проверяем, что engine.session.mount был вызван для http:// и https://
mount_calls = mock_redmine.engine.session.mount.call_args_list
prefixes = [call.args[0] for call in mount_calls]
assert "https://" in prefixes
assert "http://" in prefixes
# Проверяем retry-конфигурацию адаптера
https_adapter = next(call.args[1] for call in mount_calls if call.args[0] == "https://")
max_retries = https_adapter.max_retries
assert max_retries.total == 3
assert 429 in max_retries.status_forcelist
# -- #21: Чанкирование issue_ids --
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_chunks_large_issue_count(mock_redmine_class):
"""При >100 задач запросы разбиваются на чанки (#21)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
# 250 time entries → 250 уникальных issue_id
entries = []
for i in range(1, 251):
e = mock.MagicMock()
e.issue.id = i
e.hours = 1.0
entries.append(e)
mock_redmine.time_entry.filter.return_value = entries
# issue.filter вызывается с чанками по 100 ID
call_chunks = []
def issue_filter_side_effect(**kwargs):
ids_str = kwargs.get("issue_id", "")
call_chunks.append(ids_str)
ids = [int(x) for x in ids_str.split(",")]
return [mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids]
mock_redmine.issue.filter.side_effect = issue_filter_side_effect
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
# Должно быть 3 вызова (100 + 100 + 50)
assert len(call_chunks) == 3
assert len(call_chunks[0].split(",")) == 100
assert len(call_chunks[1].split(",")) == 100
assert len(call_chunks[2].split(",")) == 50
assert result is not None
assert len(result) == 250

View File

@@ -74,9 +74,19 @@ def test_get_default_date_range_from_env():
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_get_default_date_range_fallback(): def test_get_default_date_range_fallback():
"""Если даты не заданы -- используется хардкод-заглушка.""" """Если даты не заданы -- используется текущий месяц."""
from datetime import date, timedelta
today = date.today()
start = today.replace(day=1)
if today.month == 12:
next_month = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month = today.replace(month=today.month + 1, day=1)
end = next_month - timedelta(days=1)
result = Config.get_default_date_range() result = Config.get_default_date_range()
assert "--" in result # формат YYYY-MM-DD--YYYY-MM-DD assert result == f"{start.isoformat()}--{end.isoformat()}"
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
@@ -99,3 +109,27 @@ def test_get_redmine_verify_true_values(value):
@mock.patch.dict(os.environ, {"REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True) @mock.patch.dict(os.environ, {"REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True)
def test_get_redmine_verify_custom_path(): def test_get_redmine_verify_custom_path():
assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem" assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem"
# -- #15: .env не должен переопределять переменные окружения --
@mock.patch("dotenv.load_dotenv")
def test_env_var_takes_priority_over_dotenv(mock_load):
"""load_dotenv вызывается с override=False — env vars не перебиваются .env."""
import importlib
from redmine_reporter import config as cfg_mod
importlib.reload(cfg_mod)
mock_load.assert_called_once_with(override=False)
# -- #35: get_redmine_password должен делать .strip() --
@mock.patch.dict(os.environ, {"REDMINE_PASSWORD": " secret123 "}, clear=True)
def test_get_redmine_password_strips_whitespace():
"""Пароль обрезается от whitespace, как и все остальные геттеры."""
assert Config.get_redmine_password() == "secret123"

View File

@@ -1,4 +1,5 @@
import io import io
import json
from typing import List from typing import List
from unittest import mock from unittest import mock
@@ -7,9 +8,12 @@ from odf.opendocument import OpenDocument, OpenDocumentText
from redmine_reporter.formatters.console import CompactFormatter, TableFormatter from redmine_reporter.formatters.console import CompactFormatter, TableFormatter
from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.csv import CSVFormatter
from redmine_reporter.formatters.factory import get_formatter_by_extension
from redmine_reporter.formatters.html import HTMLFormatter from redmine_reporter.formatters.html import HTMLFormatter
from redmine_reporter.formatters.json import JSONFormatter
from redmine_reporter.formatters.markdown import MarkdownFormatter from redmine_reporter.formatters.markdown import MarkdownFormatter
from redmine_reporter.formatters.odt import ODTFormatter from redmine_reporter.formatters.odt import ODTFormatter
from redmine_reporter.formatters.xlsx import XLSXFormatter
from redmine_reporter.types import ReportRow from redmine_reporter.types import ReportRow
@@ -114,8 +118,6 @@ def odt_formatter():
mock_file = mock.MagicMock() mock_file = mock.MagicMock()
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes)) mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
mock_file.__exit__ = mock.MagicMock(return_value=False) mock_file.__exit__ = mock.MagicMock(return_value=False)
mock_path = mock.MagicMock()
mock_path.open.return_value = mock_file
with mock.patch( with mock.patch(
"redmine_reporter.formatters.odt.resources.files", "redmine_reporter.formatters.odt.resources.files",
@@ -128,12 +130,104 @@ def odt_formatter():
yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31") yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31")
# -- Тесты упаковки formatters как полноценного пакета --
def test_formatters_is_regular_package():
"""redmine_reporter.formatters — полноценный пакет с __init__.py, не namespace."""
import redmine_reporter.formatters
assert hasattr(redmine_reporter.formatters, "__file__")
assert redmine_reporter.formatters.__file__.endswith("__init__.py")
def test_formatters_found_by_setuptools():
"""setuptools.find_packages находит formatters как полноценный пакет."""
import os
from setuptools import find_packages
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
found = find_packages(where=project_root, include=["redmine_reporter*"])
assert "redmine_reporter.formatters" in found
# -- Тесты ленивого импорта ODT (#25) --
def _simulate_missing_odfpy():
"""Вспомогательная функция: подготавливает очистку кэша модулей odf и odt.
Возвращает словарь сохранённых модулей для последующего восстановления.
"""
import sys
saved = {}
for key in list(sys.modules.keys()):
if key == "odf" or key.startswith("odf.") or key == "redmine_reporter.formatters.odt":
saved[key] = sys.modules.pop(key)
return saved
def _restore_modules(saved):
import sys
sys.modules.update(saved)
def test_get_formatter_odt_returns_none_without_odfpy():
"""Без odfpy — get_formatter_by_extension('.odt') возвращает None, не падает."""
import builtins
from redmine_reporter.formatters.factory import get_formatter_by_extension
real_import = builtins.__import__
def blocking_import(name, *args, **kwargs):
if name == "odf" or name.startswith("odf."):
raise ImportError(f"No module named '{name}'")
return real_import(name, *args, **kwargs)
saved = _simulate_missing_odfpy()
try:
with mock.patch("builtins.__import__", side_effect=blocking_import):
result = get_formatter_by_extension(
".odt", author="test", from_date="2026-01-01", to_date="2026-01-31"
)
assert result is None
finally:
_restore_modules(saved)
def test_get_formatter_csv_works_without_odfpy():
"""Без odfpy — get_formatter_by_extension('.csv') работает нормально."""
import builtins
from redmine_reporter.formatters.factory import get_formatter_by_extension
real_import = builtins.__import__
def blocking_import(name, *args, **kwargs):
if name == "odf" or name.startswith("odf."):
raise ImportError(f"No module named '{name}'")
return real_import(name, *args, **kwargs)
saved = _simulate_missing_odfpy()
try:
with mock.patch("builtins.__import__", side_effect=blocking_import):
result = get_formatter_by_extension(".csv")
assert result is not None
finally:
_restore_modules(saved)
# -- Параметризованные тесты текстовых форматтеров -- # -- Параметризованные тесты текстовых форматтеров --
TEXT_FORMATTER_FACTORIES = [ TEXT_FORMATTER_FACTORIES = [
("table", lambda: TableFormatter()), ("table", lambda: TableFormatter()),
("compact", lambda: CompactFormatter()), ("compact", lambda: CompactFormatter()),
("csv", lambda: CSVFormatter()), ("csv", lambda: CSVFormatter()),
("json", lambda: JSONFormatter()),
("markdown", lambda: MarkdownFormatter()), ("markdown", lambda: MarkdownFormatter()),
] ]
@@ -161,6 +255,10 @@ def test_text_formatter_contains_key_content(fake_rows, name, factory):
# В CSV issue_id и subject -- отдельные колонки # В CSV issue_id и subject -- отдельные колонки
assert "101" in output assert "101" in output
assert "Реализовать фичу X" in output assert "Реализовать фичу X" in output
elif name == "json":
# В JSON issue_id -- число, subject -- отдельное поле
assert '"issue_id": 101' in output
assert "Реализовать фичу X" in output
else: else:
assert "101. Реализовать фичу X" in output assert "101. Реализовать фичу X" in output
@@ -185,6 +283,68 @@ def test_compact_formatter_save_raises(fake_rows):
CompactFormatter().save(fake_rows, "/dev/null") CompactFormatter().save(fake_rows, "/dev/null")
def test_csv_save_writes_utf8_bom(fake_rows, tmp_path):
"""CSV-файл начинается с UTF-8 BOM для корректного открытия в Excel (#26)."""
output = tmp_path / "report.csv"
CSVFormatter().save(fake_rows, str(output))
content = output.read_bytes()
assert content[:3] == b"\xef\xbb\xbf" # UTF-8 BOM
def test_csv_uses_full_values_not_display(fake_rows):
"""CSV экспортирует полные project/version, а не display-значения (#31).
В отличие от консольных и Markdown форматтеров (display_*), CSV содержит
полные значения в каждой строке — это корректно для табличного формата.
"""
output = CSVFormatter().format(fake_rows)
lines = output.strip().split("\n")
# Header + 7 data rows = 8 lines
assert len(lines) == 8
# Вторая строка данных (lines[2]) имеет display_project="" и display_version="",
# но CSV должен содержать полные значения
assert "Проект A" in lines[2]
assert "v1.0" in lines[2]
def test_json_save_writes_parsable_data(fake_rows, tmp_path):
"""JSON-файл содержит валидный JSON со всеми строками отчёта."""
output = tmp_path / "report.json"
JSONFormatter().save(fake_rows, str(output))
data = json.loads(output.read_text(encoding="utf-8"))
assert len(data) == len(fake_rows)
assert data[0]["project"] == "Проект A"
assert data[0]["issue_id"] == 101
def test_get_formatter_by_extension_json():
"""get_formatter_by_extension('.json') возвращает JSONFormatter."""
formatter = get_formatter_by_extension(".json")
assert isinstance(formatter, JSONFormatter)
def test_get_formatter_by_extension_xlsx():
"""get_formatter_by_extension('.xlsx') возвращает XLSXFormatter."""
formatter = get_formatter_by_extension(".xlsx")
assert isinstance(formatter, XLSXFormatter)
def test_xlsx_save_creates_valid_file(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
assert ws.title == "Report"
assert ws["A1"].value == "Project"
assert ws["A2"].value == "Проект A"
assert ws["C2"].value == 101
assert ws.max_row == len(fake_rows) + 1 # header + data
def test_markdown_formatter_escapes_table_cells(): def test_markdown_formatter_escapes_table_cells():
rows = make_fake_report_rows() rows = make_fake_report_rows()
rows[0]["project"] = "A|B" rows[0]["project"] = "A|B"
@@ -210,6 +370,13 @@ def test_html_formatter_escapes_cells():
assert "Fix <tag>" not in output assert "Fix <tag>" not in output
def test_html_output_has_doctype_and_charset(fake_rows):
"""HTML-отчёт содержит DOCTYPE и meta charset для корректной кодировки (#27)."""
output = HTMLFormatter().format(fake_rows)
assert "<!DOCTYPE html>" in output
assert '<meta charset="utf-8">' in output
# -- Тесты ODT форматтера -- # -- Тесты ODT форматтера --
@@ -219,6 +386,33 @@ def test_odt_formatter_returns_opendocument(fake_rows, odt_formatter):
assert isinstance(result, OpenDocument) assert isinstance(result, OpenDocument)
def test_odt_empty_author_no_garbage_in_header(fake_rows):
"""При пустом авторе заголовок не содержит мусорных символов (#30)."""
odt_bytes = _make_empty_odt_bytes()
mock_file = mock.MagicMock()
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
mock_file.__exit__ = mock.MagicMock(return_value=False)
with mock.patch(
"redmine_reporter.formatters.odt.resources.files",
return_value=mock.MagicMock(
joinpath=mock.MagicMock(
return_value=mock.MagicMock(open=mock.MagicMock(return_value=mock_file))
)
),
):
formatter = ODTFormatter(author="", from_date="2026-01-01", to_date="2026-01-31")
doc = formatter.format(fake_rows)
from odf.text import P
paragraphs = doc.text.getElementsByType(P)
header_text = paragraphs[0].firstChild.data
assert not header_text.startswith(".")
assert "Отчет за месяц" in header_text
def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path): def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
"""ODT можно сохранить -- файл валиден (сигнатура ZIP).""" """ODT можно сохранить -- файл валиден (сигнатура ZIP)."""
odt_bytes = _make_empty_odt_bytes() odt_bytes = _make_empty_odt_bytes()
@@ -240,3 +434,40 @@ def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
assert output_file.exists() assert output_file.exists()
assert output_file.read_bytes()[:2] == b"PK" # сигнатура ZIP assert output_file.read_bytes()[:2] == b"PK" # сигнатура ZIP
def test_odt_has_covered_cells_for_spans(fake_rows):
"""ODT содержит covered-table-cell для замещённых ячеек при объединении (#13).
Тестовые данные (fake_rows):
Проект A: v1.0(2 задачи), v2.0(1) → project span=3
row1: project+version (0 covered)
row2: covered project + covered version (2)
row3: covered project + new version cell (1)
Проект B: <N/A>(1) → span=1, нет covered
Проект C: v1.0(1), v1.1(2) → project span=3
row5: project+version (0 covered)
row6: covered project + new version cell (1)
row7: covered project + covered version (2)
Итого: 6 covered cells
"""
odt_bytes = _make_empty_odt_bytes()
mock_file = mock.MagicMock()
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
mock_file.__exit__ = mock.MagicMock(return_value=False)
with mock.patch(
"redmine_reporter.formatters.odt.resources.files",
return_value=mock.MagicMock(
joinpath=mock.MagicMock(
return_value=mock.MagicMock(open=mock.MagicMock(return_value=mock_file))
)
),
):
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
doc = formatter.format(fake_rows)
from odf.table import CoveredTableCell
covered_cells = doc.getElementsByType(CoveredTableCell)
assert len(covered_cells) == 6

View File

@@ -1,4 +1,9 @@
from redmine_reporter.report_builder import STATUS_TRANSLATION, build_grouped_report from redmine_reporter.report_builder import (
STATUS_TRANSLATION,
build_grouped_report,
calculate_summary,
group_rows_by_project_and_version,
)
class MockIssue: class MockIssue:
@@ -56,7 +61,61 @@ def test_build_grouped_report_grouping():
assert rows[0]["status_ru"] == "В работе" assert rows[0]["status_ru"] == "В работе"
assert rows[0]["time_text"] == "" assert rows[0]["time_text"] == ""
assert rows[0]["hours"] == 2.0
assert rows[1]["time_text"] == "1ч 30м" assert rows[1]["time_text"] == "1ч 30м"
assert rows[1]["hours"] == 1.5
# -- #22: Сводка по времени --
def test_calculate_summary_totals():
"""Сводка содержит общее время и разбивку по проектам/версиям."""
rows = [
{
"project": "Камеры",
"version": "v2.5.0",
"issue_id": 101,
"subject": "Фича A",
"status_ru": "В работе",
"time_text": "",
"hours": 2.0,
},
{
"project": "Камеры",
"version": "v2.5.0",
"issue_id": 102,
"subject": "Баг B",
"status_ru": "Решена",
"time_text": "1ч 30м",
"hours": 1.5,
},
{
"project": "ПО",
"version": "<N/A>",
"issue_id": 201,
"subject": "Доки",
"status_ru": "Ожидание",
"time_text": "",
"hours": 4.0,
},
]
summary = calculate_summary(rows)
assert summary["total"] == 7.5
assert summary["project:Камеры"] == 3.5
assert summary["project:ПО"] == 4.0
assert summary["version:Камеры::v2.5.0"] == 3.5
assert summary["version:ПО::<N/A>"] == 4.0
def test_calculate_summary_empty():
"""Сводка для пустого списка -- только total = 0."""
summary = calculate_summary([])
assert summary == {"total": 0.0}
# -- #19: Общая функция группировки --
def test_build_grouped_report_new_version_same_project(): def test_build_grouped_report_new_version_same_project():
@@ -105,3 +164,116 @@ def test_build_grouped_report_preserves_issue_id_and_subject():
rows = build_grouped_report(issues) rows = build_grouped_report(issues)
assert rows[0]["issue_id"] == 42 assert rows[0]["issue_id"] == 42
assert rows[0]["subject"] == "Моя задача" assert rows[0]["subject"] == "Моя задача"
def test_build_grouped_report_deterministic_order_within_group():
"""Задачи в одной группе проект+версия упорядочены по issue.id
независимо от порядка на входе (#33)."""
issues = [
(MockIssue("P", "Task C", "New", "v1.0", 103), 1.0),
(MockIssue("P", "Task A", "New", "v1.0", 101), 1.0),
(MockIssue("P", "Task B", "New", "v1.0", 102), 1.0),
]
rows = build_grouped_report(issues)
assert [r["issue_id"] for r in rows] == [101, 102, 103]
# -- #19: Общая функция группировки --
def test_group_rows_basic():
"""Два проекта, в одном две версии — структура корректна."""
rows = [
{
"project": "A",
"version": "v1",
"issue_id": 1,
"subject": "T1",
"status_ru": "S",
"time_text": "",
},
{
"project": "A",
"version": "v2",
"issue_id": 2,
"subject": "T2",
"status_ru": "S",
"time_text": "",
},
{
"project": "B",
"version": "v1",
"issue_id": 3,
"subject": "T3",
"status_ru": "S",
"time_text": "",
},
]
grouped = group_rows_by_project_and_version(rows)
assert list(grouped.keys()) == ["A", "B"]
assert list(grouped["A"].keys()) == ["v1", "v2"]
assert list(grouped["B"].keys()) == ["v1"]
assert len(grouped["A"]["v1"]) == 1
assert len(grouped["A"]["v2"]) == 1
assert len(grouped["B"]["v1"]) == 1
def test_group_rows_multiple_tasks_per_version():
"""Несколько задач в одной версии — все попадают в список."""
rows = [
{
"project": "A",
"version": "v1",
"issue_id": 1,
"subject": "T1",
"status_ru": "S",
"time_text": "",
},
{
"project": "A",
"version": "v1",
"issue_id": 2,
"subject": "T2",
"status_ru": "S",
"time_text": "",
},
{
"project": "A",
"version": "v1",
"issue_id": 3,
"subject": "T3",
"status_ru": "S",
"time_text": "",
},
]
grouped = group_rows_by_project_and_version(rows)
assert len(grouped["A"]["v1"]) == 3
assert [r["issue_id"] for r in grouped["A"]["v1"]] == [1, 2, 3]
def test_group_rows_preserves_row_data():
"""Строки в группировке — те же объекты, без потери данных."""
rows = [
{
"project": "P",
"version": "v1",
"issue_id": 42,
"subject": "Task",
"status_ru": "Готово",
"time_text": "",
},
]
grouped = group_rows_by_project_and_version(rows)
row = grouped["P"]["v1"][0]
assert row["issue_id"] == 42
assert row["subject"] == "Task"
assert row["status_ru"] == "Готово"
assert row["time_text"] == ""
def test_group_rows_empty():
"""Пустой список — пустой словарь."""
assert group_rows_by_project_and_version([]) == {}

View File

@@ -78,3 +78,35 @@ def test_get_version_none_attribute():
fixed_version = None fixed_version = None
assert get_version(MockIssue()) == "<N/A>" assert get_version(MockIssue()) == "<N/A>"
def test_get_version_with_redminelib_version_object():
"""redminelib Version: str() возвращает ID, .name — человекочитаемое имя."""
class MockVersion:
"""Имитирует redminelib.resources.Version — str() даёт ID."""
def __init__(self, vid, name):
self.id = vid
self.name = name
def __str__(self):
return str(self.id)
class MockIssue:
fixed_version = MockVersion(42, "v2.5.0")
assert get_version(MockIssue()) == "v2.5.0"
def test_get_version_falls_back_to_str_when_no_name():
"""Если у объекта версии нет .name — fallback на str()."""
class MockVersionNoName:
def __str__(self):
return "fallback-id"
class MockIssue:
fixed_version = MockVersionNoName()
assert get_version(MockIssue()) == "fallback-id"