5 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
0e4e0f3ee2 Bump version: v1.5.0 2026-05-22 17:49:18 +07:00
Кокос Артем Николаевич
2db0ab1f0b Tighten configuration and export handling 2026-05-22 17:41:56 +07:00
Кокос Артем Николаевич
8bc8181ce3 Add Redmine API token authentication 2026-05-22 17:19:00 +07:00
Artem Kokos
7bc6e024c0 Quick fixes & tests 2026-03-28 23:55:46 +07:00
Кокос Артем Николаевич
06cd57e2c4 Blacked 2026-02-05 15:31:31 +07:00
21 changed files with 926 additions and 348 deletions

175
README.md
View File

@@ -1,60 +1,52 @@
# redmine-reporter # redmine-reporter
Инструмент для генерации отчётов по задачам в Redmine на основе ваших записей о затраченном времени. CLI-инструмент для генерации отчётов по задачам Redmine на основе записей о затраченном времени.
> Предназначен для внутреннего использования в Eltex. Работает с `https://red.eltex.loc/`.
📄 **Лицензия**: [MIT](./LICENSE) — делайте что угодно. Проект предназначен для внутреннего использования с `https://red.eltex.loc/`.
--- Лицензия: MIT.
## 🔧 Возможности ## Возможности
- Безопасная передача учётных данных через переменные окружения или `.env` - Получение time entries текущего пользователя из Redmine.
- Группировка задач по проекту и версии - Авторизация через Redmine API token.
- Поддержка нескольких форматов экспорта: **ODT**, **CSV**, **Markdown**, **HTML** - Резервная авторизация через логин и пароль для обратной совместимости.
- Два режима вывода в консоль: табличный (красивая таблица) и компактный (для копирования) - Группировка задач по проекту и версии.
- Перевод статусов задач на русский язык - Перевод статусов задач на русский язык.
- Автоматическое определение месяца отчёта по дате окончания периода (для **ODT**) - Вывод в консоль в табличном или компактном виде.
- Простой CLI с понятными аргументами - Экспорт в ODT, CSV, Markdown и HTML.
- Поддержка настройки диапазона дат по умолчанию через `.env` - Автоматическое определение месяца ODT-отчёта по конечной дате периода.
- Настройка периода отчёта по умолчанию через `.env`.
--- ## Установка
## 🚀 Установка и настройка
### 1. Клонируйте репозиторий
```bash ```bash
git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git
cd redmine-reporter cd redmine-reporter
```
### 2. Создайте виртуальное окружение и установите зависимости
```bash
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install --upgrade pip pip install --upgrade pip
pip install . pip install .
``` ```
> 💡 Установка в виртуальное окружение — стандарт для Python-инструментов. Это безопасно и не влияет на систему. ## Настройка
### 3. Настройте доверие к корпоративному сертификату (обязательно!) Создайте файл `.env` в корне проекта. Файл не должен попадать в git.
По умолчанию Python использует собственный набор сертификатов (`certifi`), который **не включает** внутренние CA Eltex. Рекомендуемый вариант авторизации:
Чтобы избежать ошибки `CERTIFICATE_VERIFY_FAILED`, выполните **один раз**:
```bash ```ini
cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi) REDMINE_URL=https://red.eltex.loc/
REDMINE_API_KEY=ваш_api_token
REDMINE_AUTHOR=Иванов Иван Иванович
DEFAULT_FROM_DATE=2026-01-01
DEFAULT_TO_DATE=2026-01-31
``` ```
> ✅ Это безопасно: вы просто добавляете доверенные системные сертификаты к Python. Если задан `REDMINE_API_KEY`, он используется в первую очередь.
> Не используйте `verify=False` — это создаёт уязвимость.
### 4. Настройте учётные данные Резервный вариант авторизации:
Создайте файл `.env` в корне проекта (**никогда не коммитьте его!**):
```ini ```ini
REDMINE_URL=https://red.eltex.loc/ REDMINE_URL=https://red.eltex.loc/
@@ -62,103 +54,98 @@ REDMINE_USER=ваш.логин
REDMINE_PASSWORD=ваш_пароль REDMINE_PASSWORD=ваш_пароль
REDMINE_AUTHOR=Иванов Иван Иванович REDMINE_AUTHOR=Иванов Иван Иванович
# Опционально: диапазон дат по умолчанию
DEFAULT_FROM_DATE=2026-01-01 DEFAULT_FROM_DATE=2026-01-01
DEFAULT_TO_DATE=2026-01-31 DEFAULT_TO_DATE=2026-01-31
``` ```
Альтернатива — задать переменные вручную: Переменные окружения:
```bash | Переменная | Обязательность | Описание |
export REDMINE_URL=https://red.eltex.loc/ | --- | --- | --- |
export REDMINE_USER=ваш.логин | `REDMINE_URL` | Да | URL Redmine. |
export REDMINE_PASSWORD=... | `REDMINE_API_KEY` | Да, если нет логина и пароля | Redmine API token. |
export REDMINE_AUTHOR="Иванов Иван Иванович" | `REDMINE_USER` | Да, если нет токена | Логин Redmine. |
``` | `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. |
| `REDMINE_AUTHOR` | Нет | Имя автора для ODT-отчёта. |
| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию в формате `YYYY-MM-DD`. |
| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию в формате `YYYY-MM-DD`. |
| `REDMINE_VERIFY` | Нет | Настройка TLS-проверки для Redmine API. |
> 🔐 Рекомендуется использовать аккаунт с минимальными правами (только чтение time entries и задач). `REDMINE_VERIFY` поддерживает значения:
--- - пустое значение или отсутствие переменной: `/etc/ssl/certs/ca-certificates.crt`;
- `true`, `1`, `yes`, `on`: стандартная проверка сертификатов `requests`;
- `false`, `0`, `no`, `off`: отключить проверку сертификатов;
- любой другой текст: путь к CA bundle.
## ▶️ Использование Отключать проверку сертификатов не рекомендуется.
Перед каждым запуском активируйте окружение: ## Использование
```bash ```bash
source .venv/bin/activate source .venv/bin/activate
``` ```
Затем: Отчёт за период по умолчанию:
```bash ```bash
# Отчёт за период по умолчанию (из .env или встроенный)
redmine-reporter redmine-reporter
```
# Отчёт за произвольный период Отчёт за произвольный период:
```bash
redmine-reporter --date 2026-02-01--2026-02-28 redmine-reporter --date 2026-02-01--2026-02-28
```
# Компактный вывод (удобно копировать в письмо) Период должен быть задан в формате `YYYY-MM-DD--YYYY-MM-DD`. Начальная дата не может быть позже конечной.
Компактный вывод:
```bash
redmine-reporter --compact redmine-reporter --compact
```
# Экспорт в ODT Экспорт:
```bash
redmine-reporter --output report.odt redmine-reporter --output report.odt
# Экспорт в CSV
redmine-reporter --output report.csv redmine-reporter --output report.csv
# Экспорт в Markdown
redmine-reporter --output report.md redmine-reporter --output report.md
# Экспорт в HTML
redmine-reporter --output report.html redmine-reporter --output report.html
``` ```
> 💡 **Автоматика в ODT-отчёте**: ODT-отчёт:
> - Месяц в заголовке определяется **автоматически** по дате окончания периода (`to_date`).
> Например: `2025-12-20--2026-01-15` → **«Январь»**.
> - Имя автора берётся из `REDMINE_AUTHOR` (в `.env`) или CLI-аргумента `--author`.
Пример содержимого `.env`: - месяц в заголовке определяется по `to_date`;
- имя автора берётся из `--author`, затем из `REDMINE_AUTHOR`;
- если автор не задан, поле автора остаётся пустым.
```ini Вывод без затраченного времени:
REDMINE_URL=https://red.eltex.loc/
REDMINE_USER=ivanov.ivan
REDMINE_PASSWORD=supersecret
REDMINE_AUTHOR=Иванов Иван
DEFAULT_FROM_DATE=2026-01-01 ```bash
DEFAULT_TO_DATE=2026-01-31 redmine-reporter --no-time
``` ```
Пример консольного вывода: ## Разработка
``` Установка зависимостей для разработки:
✅ Total issues: 7 [2026-01-01--2026-01-31]
╒════════════╤═══════════╤══════════════════════════════════════╤═══════════╤════════════╕
│ Проект │ Версия │ Задача │ Статус │ Затрачено │
╞════════════╪═══════════╪══════════════════════════════════════╪═══════════╪════════════╡
│ Камеры │ v2.5.0 │ 12345. Поддержка нового датчика │ В работе │ 2ч 30м │
│ │ │ 12346. Исправить утечку памяти │ Решена │ 2ч │
│ ПО │ <N/A> │ 12350. Обновить документацию │ Ожидание │ 12ч │
╘════════════╧═══════════╧══════════════════════════════════════╧═══════════╧════════════╛
```
---
## 🛠 Разработка
Для участия в разработке:
```bash ```bash
pip install -e ".[dev]" pip install -e ".[dev]"
pytest
black .
isort .
``` ```
--- Проверки:
> 🔒 **Важно**: ```bash
> - Никогда не коммитьте `.env`, пароли или логины. pytest
> - Файл `.gitignore` уже исключает все чувствительные артефакты. ruff check redmine_reporter tests
> - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine. black redmine_reporter tests
isort redmine_reporter tests
```
## Безопасность
- Не коммитьте `.env`, API token, пароль или логин.
- Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач.
- Инструмент работает только в режиме чтения и не изменяет данные в Redmine.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "redmine-reporter" name = "redmine-reporter"
version = "1.4.1" version = "1.5.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" }]
@@ -51,3 +51,10 @@ target-version = ['py39']
[tool.isort] [tool.isort]
profile = "black" profile = "black"
multi_line_output = 3 multi_line_output = 3
[tool.mypy]
warn_unused_configs = true
[[tool.mypy.overrides]]
module = ["odf.*", "redminelib.*", "tabulate"]
ignore_missing_imports = true

View File

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

View File

@@ -1,13 +1,14 @@
import os
import sys
import argparse import argparse
import os
import re
import sys
from datetime import datetime
from typing import List, Optional from typing import List, Optional
from redminelib.resources import Issue
from .config import Config
from .client import fetch_issues_with_spent_time from .client import fetch_issues_with_spent_time
from .config import Config
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
from .formatters.factory import get_formatter_by_extension, get_console_formatter
def parse_date_range(date_arg: str) -> tuple[str, str]: def parse_date_range(date_arg: str) -> tuple[str, str]:
@@ -16,38 +17,49 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
parts = date_arg.split("--", 1) parts = date_arg.split("--", 1)
if len(parts) != 2: if len(parts) != 2:
raise ValueError("Invalid date range format") raise ValueError("Invalid date range format")
return 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}"
if not re.fullmatch(date_pattern, from_date) or not re.fullmatch(date_pattern, to_date):
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try:
start = datetime.strptime(from_date, "%Y-%m-%d").date()
end = datetime.strptime(to_date, "%Y-%m-%d").date()
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
if start > end:
raise ValueError("Date range start must be less than or equal to end")
return start.isoformat(), end.isoformat()
def main(argv: Optional[List[str]] = None) -> int: def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="redmine-reporter", prog="redmine-reporter",
description="Generate Redmine issue report based on your time entries." description="Generate Redmine issue report based on your time entries.",
) )
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: %(default)s)"
help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default from .env or %(default)s)" 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",
action="store_true", action="store_true",
help="Use compact plain-text output instead of table" help="Use compact plain-text output instead of table",
) )
parser.add_argument( parser.add_argument(
"--output", "--output",
help="Path to output .odt file (e.g., report.odt). If omitted, prints to stdout." help="Path to output file (.odt, .csv, .md, .html). If omitted, prints to stdout.",
) )
parser.add_argument( parser.add_argument(
"--author", "--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"
default="",
help="Override author name from .env (REDMINE_AUTHOR)"
) )
parser.add_argument( parser.add_argument(
"--no-time", "--no-time", action="store_true", help="Do not include spent time into table"
action="store_true",
help="Do not include spent time into table"
) )
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -80,14 +92,26 @@ def main(argv: Optional[List[str]] = None) -> int:
if args.output: if args.output:
output_ext = os.path.splitext(args.output)[1].lower() output_ext = os.path.splitext(args.output)[1].lower()
formatter = get_formatter_by_extension(output_ext, if not output_ext:
print(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md или .html",
file=sys.stderr,
)
return 1
formatter = get_formatter_by_extension(
output_ext,
author=Config.get_author(args.author), author=Config.get_author(args.author),
from_date=from_date, from_date=from_date,
to_date=to_date to_date=to_date,
) )
if not formatter: if not formatter:
print(f"❌ Неизвестный формат файла: {output_ext}", file=sys.stderr) known_exts = ", ".join([".odt", ".csv", ".md", ".html"])
print(
f"❌ Неизвестный формат файла: {output_ext!r}. Поддерживаются: {known_exts}",
file=sys.stderr,
)
return 1 return 1
try: try:
@@ -95,12 +119,15 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"✅ Report saved to {args.output}") print(f"✅ Report saved to {args.output}")
except ImportError as e: except ImportError as e:
if output_ext == ".odt": if output_ext == ".odt":
print("❌ odfpy is not installed. Install with: pip install odfpy", file=sys.stderr) print(
"❌ odfpy is not installed. Install with: pip install odfpy",
file=sys.stderr,
)
else: else:
print(f"❌ Import error: {e}", file=sys.stderr) print(f"❌ Import error: {e}", file=sys.stderr)
return 1 return 1
except Exception as e: except Exception as e:
fmt = "ODT" if output_ext == ".odt" else ("CSV" if output_ext == ".csv" else "Markdown") fmt = output_ext.lstrip(".").upper()
print(f"{fmt} export error: {e}", file=sys.stderr) print(f"{fmt} export error: {e}", file=sys.stderr)
return 1 return 1

View File

@@ -1,11 +1,26 @@
from typing import List, Optional, Dict, Tuple from typing import Any, Dict, List, Optional, Tuple
from redminelib import Redmine from redminelib import Redmine
from redminelib.resources import Issue from redminelib.resources import Issue
from .config import Config from .config import Config
from .utils import get_version from .utils import get_version
def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[Tuple[Issue, float]]]: def _get_redmine_auth_kwargs() -> Dict[str, Any]:
"""Return Redmine auth kwargs. API key has priority over legacy password auth."""
api_key = Config.get_redmine_api_key()
if api_key:
return {"key": api_key}
return {
"username": Config.get_redmine_user(),
"password": Config.get_redmine_password(),
}
def fetch_issues_with_spent_time(
from_date: str, to_date: str
) -> Optional[List[Tuple[Issue, float]]]:
""" """
Fetch unique issues linked to time entries of the current user in given date range, Fetch unique issues linked to time entries of the current user in given date range,
along with total spent hours per issue. along with total spent hours per issue.
@@ -13,24 +28,21 @@ def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[
""" """
redmine = Redmine( redmine = Redmine(
Config.REDMINE_URL, Config.get_redmine_url(),
username=Config.REDMINE_USER, **_get_redmine_auth_kwargs(),
password=Config.REDMINE_PASSWORD, requests={"verify": Config.get_redmine_verify()},
requests={'verify': '/etc/ssl/certs/ca-certificates.crt'}
) )
current_user = redmine.user.get('current') current_user = redmine.user.get("current")
time_entries = redmine.time_entry.filter( time_entries = redmine.time_entry.filter(
user_id=current_user.id, user_id=current_user.id, from_date=from_date, to_date=to_date
from_date=from_date,
to_date=to_date
) )
# Агрегируем часы по issue.id # Агрегируем часы по issue.id
spent_time: Dict[int, float] = {} spent_time: Dict[int, 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
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) + float(entry.hours)
@@ -39,12 +51,8 @@ def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[
return None return None
# Загружаем полные объекты задач # Загружаем полные объекты задач
issue_list_str = ','.join(str(i) for i in issue_ids) issue_list_str = ",".join(str(i) for i in issue_ids)
issues = redmine.issue.filter( issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc")
issue_id=issue_list_str,
status_id='*',
sort='project:asc'
)
# Сопоставляем задачи с суммарным временем # Сопоставляем задачи с суммарным временем
result = [] result = []

View File

@@ -1,39 +1,68 @@
import os import os
from dotenv import load_dotenv from typing import Union
from dotenv import load_dotenv
load_dotenv() load_dotenv()
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
FALSE_VALUES = {"0", "false", "no", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"}
class Config: class Config:
REDMINE_URL = os.getenv("REDMINE_URL", "").strip().rstrip("/") @classmethod
REDMINE_USER = os.getenv("REDMINE_USER") def get_redmine_url(cls) -> str:
REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD") return os.getenv("REDMINE_URL", "").strip().rstrip("/")
REDMINE_AUTHOR = os.getenv("REDMINE_AUTHOR")
DEFAULT_FROM_DATE = os.getenv("DEFAULT_FROM_DATE") @classmethod
DEFAULT_TO_DATE = os.getenv("DEFAULT_TO_DATE") def get_redmine_api_key(cls) -> str:
return os.getenv("REDMINE_API_KEY", "").strip()
@classmethod
def get_redmine_user(cls) -> str:
return os.getenv("REDMINE_USER", "").strip()
@classmethod
def get_redmine_password(cls) -> str:
return os.getenv("REDMINE_PASSWORD", "")
@classmethod
def get_redmine_verify(cls) -> Union[bool, str]:
value = os.getenv("REDMINE_VERIFY", "").strip()
if not value:
return DEFAULT_REDMINE_VERIFY
normalized = value.lower()
if normalized in FALSE_VALUES:
return False
if normalized in TRUE_VALUES:
return True
return value
@classmethod @classmethod
def get_author(cls, cli_author: str = "") -> str: def get_author(cls, cli_author: str = "") -> str:
"""Возвращает автора: из CLI если задан, иначе из .env, иначе — заглушку.""" """Возвращает автора: из CLI если задан, иначе из .env, иначе — заглушку."""
if cli_author: if cli_author:
return cli_author return cli_author
if cls.REDMINE_AUTHOR: return os.getenv("REDMINE_AUTHOR", "").strip()
return cls.REDMINE_AUTHOR
return ""
@classmethod @classmethod
def get_default_date_range(cls) -> str: def get_default_date_range(cls) -> str:
if cls.DEFAULT_FROM_DATE and cls.DEFAULT_TO_DATE: default_from_date = os.getenv("DEFAULT_FROM_DATE", "").strip()
return f"{cls.DEFAULT_FROM_DATE}--{cls.DEFAULT_TO_DATE}" default_to_date = os.getenv("DEFAULT_TO_DATE", "").strip()
if default_from_date and default_to_date:
return f"{default_from_date}--{default_to_date}"
# fallback hardcoded # fallback hardcoded
return "2025-12-19--2026-01-31" return "2025-12-19--2026-01-31"
@classmethod @classmethod
def validate(cls) -> None: def validate(cls) -> None:
if not cls.REDMINE_URL: if not cls.get_redmine_url():
raise ValueError("REDMINE_URL is required (set via env or .env)") raise ValueError("REDMINE_URL is required (set via env or .env)")
if not cls.REDMINE_USER: if cls.get_redmine_api_key():
raise ValueError("REDMINE_USER is required") return
if not cls.REDMINE_PASSWORD: if not (cls.get_redmine_user() and cls.get_redmine_password()):
raise ValueError("REDMINE_PASSWORD is required") raise ValueError(
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
)

View File

@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List from typing import Any, List
from ..types import ReportRow from ..types import ReportRow
@@ -7,13 +8,19 @@ class Formatter(ABC):
""" """
Абстрактный базовый класс для всех форматтеров. Абстрактный базовый класс для всех форматтеров.
Определяет общий интерфейс для форматирования отчета. Определяет общий интерфейс для форматирования отчета.
Контракт:
- format() возвращает строку для текстовых форматтеров (CSV, HTML, Markdown, console)
и объект OpenDocument для ODTFormatter.
- save() сохраняет результат в файл; консольные форматтеры бросают NotImplementedError.
""" """
@abstractmethod @abstractmethod
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> Any:
""" """
Форматирует список строк отчета в нужный формат. Форматирует список строк отчета в нужный формат.
Возвращает строковое представление отчета. Для текстовых форматтеров возвращает str.
Для ODTFormatter возвращает объект OpenDocument.
""" """
pass pass
@@ -22,6 +29,6 @@ class Formatter(ABC):
""" """
Сохраняет отформатированный отчет в файл по указанному пути. Сохраняет отформатированный отчет в файл по указанному пути.
Для форматтеров, которые не поддерживают сохранение (например, консольные), Для форматтеров, которые не поддерживают сохранение (например, консольные),
можно вызывать `format` и записывать результат вручную. бросает NotImplementedError.
""" """
pass pass

View File

@@ -1,22 +1,26 @@
from typing import List from typing import List
from tabulate import tabulate from tabulate import tabulate
from .base import Formatter
from ..types import ReportRow from ..types import ReportRow
from .base import Formatter
class TableFormatter(Formatter): 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:
table_rows.append([ table_rows.append(
r['display_project'], [
r['display_version'], r["display_project"],
r["display_version"],
f"{r['issue_id']}. {r['subject']}", f"{r['issue_id']}. {r['subject']}",
r['status_ru'], r["status_ru"],
r['time_text'] r["time_text"],
]) ]
)
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid") return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: List[ReportRow], output_path: str) -> None:
@@ -24,6 +28,7 @@ class TableFormatter(Formatter):
# Это делается в CLI. # Это делается в CLI.
raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.") raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.")
class CompactFormatter(Formatter): class CompactFormatter(Formatter):
"""Форматтер для компактного вывода в консоль.""" """Форматтер для компактного вывода в консоль."""

View File

@@ -1,14 +1,15 @@
import csv import csv
import io import io
from typing import List from typing import List
from .base import Formatter
from ..types import ReportRow from ..types import ReportRow
from .base import Formatter
class CSVFormatter(Formatter): class CSVFormatter(Formatter):
"""Форматтер для экспорта в CSV.""" """Форматтер для экспорта в CSV."""
def __init__(self, **kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
@@ -16,14 +17,16 @@ class CSVFormatter(Formatter):
writer = csv.writer(output, dialect="excel") writer = csv.writer(output, dialect="excel")
writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]) writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"])
for r in rows: for r in rows:
writer.writerow([ writer.writerow(
[
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"] r["time_text"],
]) ]
)
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

@@ -1,11 +1,11 @@
from typing import Dict, Type, Optional from typing import Dict, Optional, Type
from .base import Formatter from .base import Formatter
from .console import TableFormatter, CompactFormatter from .console import CompactFormatter, TableFormatter
from .csv import CSVFormatter from .csv import CSVFormatter
from .html import HTMLFormatter
from .markdown import MarkdownFormatter from .markdown import MarkdownFormatter
from .odt import ODTFormatter from .odt import ODTFormatter
from .html import HTMLFormatter
# Словарь для сопоставления расширений файлов с классами форматтеров # Словарь для сопоставления расширений файлов с классами форматтеров
FORMATTER_MAP: Dict[str, Type[Formatter]] = { FORMATTER_MAP: Dict[str, Type[Formatter]] = {

View File

@@ -1,12 +1,14 @@
from typing import List, Dict, Any from html import escape
from .base import Formatter from typing import Dict, List
from ..types import ReportRow from ..types import ReportRow
from .base import Formatter
class HTMLFormatter(Formatter): class HTMLFormatter(Formatter):
"""Форматтер для экспорта отчёта в HTML.""" """Форматтер для экспорта отчёта в HTML."""
def __init__(self, **kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
@@ -36,34 +38,38 @@ class HTMLFormatter(Formatter):
] ]
for project, versions in projects.items(): for project, versions in projects.items():
project_text = escape(project)
total_project_rows = sum(len(tasks) for tasks in versions.values()) total_project_rows = sum(len(tasks) for tasks in versions.values())
first_version_in_project = True first_version_in_project = True
for version, task_rows in versions.items(): for version, task_rows in versions.items():
version_text = escape(version)
row_span_version = len(task_rows) row_span_version = len(task_rows)
first_row_in_version = True first_row_in_version = True
for r in task_rows: for r in task_rows:
task_cell = escape(f"{r['issue_id']}. {r['subject']}")
status_text = escape(r["status_ru"])
time_text = escape(r["time_text"])
lines.append(" <tr>") lines.append(" <tr>")
# Ячейка "Проект" - только в первой строке проекта # Ячейка "Проект" - только в первой строке проекта
if first_version_in_project and first_row_in_version: if first_version_in_project and first_row_in_version:
lines.append( lines.append(
f' <td rowspan="{total_project_rows}" style="vertical-align: top;">{project}</td>' f' <td rowspan="{total_project_rows}" style="vertical-align: top;">{project_text}</td>'
) )
# Ячейка "Версия" - только в первой строке версии # Ячейка "Версия" - только в первой строке версии
if first_row_in_version: if first_row_in_version:
lines.append( lines.append(
f' <td rowspan="{row_span_version}" style="vertical-align: top;">{version}</td>' f' <td rowspan="{row_span_version}" style="vertical-align: top;">{version_text}</td>'
) )
first_row_in_version = False first_row_in_version = False
# Остальные колонки # Остальные колонки
task_cell = f"{r['issue_id']}. {r['subject']}"
lines.append(f" <td>{task_cell}</td>") lines.append(f" <td>{task_cell}</td>")
lines.append(f" <td>{r['status_ru']}</td>") lines.append(f" <td>{status_text}</td>")
lines.append(f" <td>{r['time_text']}</td>") lines.append(f" <td>{time_text}</td>")
lines.append(" </tr>") lines.append(" </tr>")

View File

@@ -1,24 +1,32 @@
from typing import List from typing import List
from .base import Formatter
from ..types import ReportRow from ..types import ReportRow
from .base import Formatter
def _escape_markdown_table_cell(value: object) -> str:
return str(value).replace("\\", "\\\\").replace("|", "\\|").replace("\n", "<br>")
class MarkdownFormatter(Formatter): class MarkdownFormatter(Formatter):
"""Форматтер для экспорта в Markdown.""" """Форматтер для экспорта в Markdown."""
def __init__(self, **kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
lines = [ lines = [
"| Проект | Версия | Задача | Статус | Затрачено |", "| Проект | Версия | Задача | Статус | Затрачено |",
"|--------|--------|--------|--------|-----------|" "|--------|--------|--------|--------|-----------|",
] ]
for r in rows: for r in rows:
task_cell = f"{r['issue_id']}. {r['subject']}" task_cell = _escape_markdown_table_cell(f"{r['issue_id']}. {r['subject']}")
lines.append( lines.append(
f"| {r['display_project']} | {r['display_version']} " f"| {_escape_markdown_table_cell(r['display_project'])} "
f"| {task_cell} | {r['status_ru']} | {r['time_text']} |" f"| {_escape_markdown_table_cell(r['display_version'])} "
f"| {task_cell} "
f"| {_escape_markdown_table_cell(r['status_ru'])} "
f"| {_escape_markdown_table_cell(r['time_text'])} |"
) )
return "\n".join(lines) return "\n".join(lines)

View File

@@ -1,13 +1,14 @@
import os
from importlib import resources from importlib import resources
from typing import List from typing import Dict, List
from odf.opendocument import load
from odf.opendocument import OpenDocument, load
from odf.style import Style, TableCellProperties, TableColumnProperties
from odf.table import Table, TableCell, TableColumn, TableRow
from odf.text import P from odf.text import P
from odf.table import Table, TableColumn, TableRow, TableCell
from odf.style import Style, TableColumnProperties, TableCellProperties
from .base import Formatter
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
class ODTFormatter(Formatter): class ODTFormatter(Formatter):
@@ -21,12 +22,12 @@ class ODTFormatter(Formatter):
self.from_date = from_date self.from_date = from_date
self.to_date = to_date self.to_date = to_date
def format(self, rows: List[ReportRow]) -> "OpenDocument": def format(self, rows: List[ReportRow]) -> OpenDocument:
""" """
Форматирует данные в объект OpenDocument. Форматирует данные в объект OpenDocument.
""" """
with resources.files("redmine_reporter").joinpath("templates", "template.odt").open("rb") as f: with resources.files("redmine_reporter").joinpath("templates/template.odt").open("rb") as f:
doc = load(f) doc = load(f)
para_style_name = "Standard" para_style_name = "Standard"
@@ -56,13 +57,19 @@ class ODTFormatter(Formatter):
# Заголовки # Заголовки
header_row = TableRow() header_row = TableRow()
for text in ["Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период"]: for text in [
"Наименование Проекта",
"Номер версии*",
"Задача",
"Статус Готовность*",
"Затрачено за отчетный период",
]:
cell = TableCell(stylename=cell_style_name) cell = TableCell(stylename=cell_style_name)
cell.addElement(P(stylename=para_style_name, text=text)) cell.addElement(P(stylename=para_style_name, text=text))
header_row.addElement(cell) header_row.addElement(cell)
table.addElement(header_row) table.addElement(header_row)
projects = {} projects: Dict[str, Dict[str, List[ReportRow]]] = {}
for r in rows: for r in rows:
project = r["project"] project = r["project"]
version = r["version"] version = r["version"]
@@ -77,7 +84,9 @@ class ODTFormatter(Formatter):
# Данные с двухуровневой группировкой и объединением ячеек # Данные с двухуровневой группировкой и объединением ячеек
for project, versions in projects.items(): for project, versions in projects.items():
total_project_rows = sum(len(rows_for_version) for rows_for_version in versions.values()) total_project_rows = sum(
len(rows_for_version) for rows_for_version in versions.values()
)
first_version_in_project = True first_version_in_project = True
for version, rows_for_version in versions.items(): for version, rows_for_version in versions.items():
@@ -91,7 +100,7 @@ class ODTFormatter(Formatter):
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)
@@ -103,9 +112,6 @@ 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:
# Пропускаем - уже объединена
pass
# Остальные колонки # Остальные колонки
task_cell = TableCell(stylename=cell_style_name) task_cell = TableCell(stylename=cell_style_name)
@@ -125,6 +131,7 @@ class ODTFormatter(Formatter):
row.addElement(time_cell) row.addElement(time_cell)
table.addElement(row) table.addElement(row)
first_version_in_project = False first_version_in_project = False
doc.text.addElement(table) doc.text.addElement(table)
@@ -137,7 +144,7 @@ class ODTFormatter(Formatter):
"«Задача» - Номер по Redmine и формулировка.", "«Задача» - Номер по Redmine и формулировка.",
"«Статус» - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена.", "«Статус» - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена.",
"«Готовность» Опциональное поле в процентах.", "«Готовность» Опциональное поле в процентах.",
"«Затрачено за отчетный период» - в днях или часах." "«Затрачено за отчетный период» - в днях или часах.",
]: ]:
doc.text.addElement(P(stylename=para_style_name, text=line)) doc.text.addElement(P(stylename=para_style_name, text=line))

View File

@@ -1,23 +1,24 @@
from typing import List, Tuple, cast from typing import List, Tuple, cast
from redminelib.resources import Issue from redminelib.resources import Issue
from .types import ReportRow from .types import ReportRow
from .utils import get_version, hours_to_human from .utils import get_version, hours_to_human
STATUS_TRANSLATION = { STATUS_TRANSLATION = {
'New': 'В работе', "New": "В работе",
'In Progress': 'В работе', "In Progress": "В работе",
'Feedback': 'В работе', "Feedback": "В работе",
'Re-opened': 'В работе', "Re-opened": "В работе",
'Code Review': 'Решена', "Code Review": "Решена",
'Wait Release': 'Закрыто', "Wait Release": "Закрыто",
'Pending': 'Ожидание', "Pending": "Ожидание",
'Resolved': 'Решена', "Resolved": "Решена",
'Testing': 'Решена', "Testing": "Решена",
'Confirming': 'Ожидание', "Confirming": "Ожидание",
'Closed': 'Закрыто', "Closed": "Закрыто",
'Rejected': 'Закрыто', "Rejected": "Закрыто",
'Frozen': 'Ожидание', "Frozen": "Ожидание",
} }
@@ -28,8 +29,14 @@ def build_grouped_report(
""" """
Преобразует список задач с затраченным временем в плоский список строк отчёта, Преобразует список задач с затраченным временем в плоский список строк отчёта,
с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений). с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений).
Предусловие: issue_hours должен быть отсортирован по (project, version).
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
""" """
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0])))
rows: List[ReportRow] = [] rows: List[ReportRow] = []
prev_project: str = "" prev_project: str = ""
prev_version: str = "" prev_version: str = ""

View File

@@ -10,8 +10,19 @@ def get_month_name_from_range(from_date: str, to_date: str) -> str:
return "Январь" return "Январь"
months = [ months = [
"", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" "Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
] ]
return months[end.month] return months[end.month]
@@ -19,7 +30,10 @@ def get_month_name_from_range(from_date: str, to_date: str) -> str:
def get_version(issue) -> str: def get_version(issue) -> str:
"""Возвращает версию задачи или '<N/A>', если не задана.""" """Возвращает версию задачи или '<N/A>', если не задана."""
return str(getattr(issue, 'fixed_version', '<N/A>')) version = getattr(issue, "fixed_version", None)
if version is None:
return "<N/A>"
return str(version)
def hours_to_human(hours: float) -> str: def hours_to_human(hours: float) -> str:

View File

@@ -1,23 +1,96 @@
import os
import sys import sys
from io import StringIO from io import StringIO
from unittest import mock from unittest import mock
from redmine_reporter.cli import main
import pytest
from redmine_reporter.cli import main, parse_date_range
VALID_ENV = {
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_API_KEY": "token",
}
@mock.patch.dict("os.environ", { @pytest.mark.parametrize(
"REDMINE_URL": "https://red.eltex.loc/", "date_arg, expected",
"REDMINE_USER": "x", [
"REDMINE_PASSWORD": "y" ("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")),
}) (" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")),
@mock.patch("redmine_reporter.client.fetch_issues_with_spent_time") ],
def test_cli_smoke(mock_fetch): )
def test_parse_date_range_valid(date_arg, expected):
assert parse_date_range(date_arg) == expected
@pytest.mark.parametrize(
"date_arg",
[
"20260101-20260131",
"2026-1-01--2026-01-31",
"2026-02-30--2026-03-01",
"2026-02-01--2026-01-31",
],
)
def test_parse_date_range_invalid(date_arg):
with pytest.raises(ValueError):
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 = [] mock_fetch.return_value = []
old_stdout = sys.stdout captured = StringIO()
sys.stdout = captured = StringIO() old_stdout, sys.stdout = sys.stdout, captured
try: try:
code = main(["--date", "2026-01-01--2026-01-31"]) code = main(["--date", "2026-01-01--2026-01-31"])
assert code == 0
output = captured.getvalue()
assert "Total issues: 0" in output
finally: finally:
sys.stdout = old_stdout 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("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_returns_zero_on_no_entries(mock_fetch):
"""None от fetch (нет time entries) -- выход 0."""
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31"])
assert code == 0
@mock.patch.dict(os.environ, {}, clear=True)
def test_cli_config_error():
"""Невалидный конфиг -- выход 1."""
code = main(["--date", "2026-01-01--2026-01-31"])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_invalid_date_format():
"""Неверный формат даты -- выход 1."""
code = main(["--date", "20260101-20260131"])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
"""Неизвестное расширение файла -- выход 1."""
mock_fetch.return_value = []
output = str(tmp_path / "report.xyz")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_output_without_extension(mock_fetch, tmp_path):
"""Файл без расширения -- выход 1 с подсказкой."""
mock_fetch.return_value = []
output = str(tmp_path / "report")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1

View File

@@ -1,17 +1,29 @@
import pytest import os
from unittest import mock from unittest import mock
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
PASSWORD_ENV = {
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_USER": "user",
"REDMINE_PASSWORD": "password",
}
@mock.patch("redmine_reporter.client.Redmine") def _configure_current_user(mock_redmine, user_id=1):
def test_fetch_issues_with_spent_time(mock_redmine_class):
# Подготовка моков
mock_redmine = mock_redmine_class.return_value
mock_user = mock.MagicMock() mock_user = mock.MagicMock()
mock_user.id = 123 mock_user.id = user_id
mock_redmine.user.get.return_value = mock_user mock_redmine.user.get.return_value = mock_user
# Два time entry на одну задачу
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
"""Два time entry на одну задачу -- часы суммируются."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_entry1 = mock.MagicMock() mock_entry1 = mock.MagicMock()
mock_entry1.issue.id = 101 mock_entry1.issue.id = 101
mock_entry1.hours = 2.0 mock_entry1.hours = 2.0
@@ -20,7 +32,6 @@ def test_fetch_issues_with_spent_time(mock_redmine_class):
mock_entry2.hours = 1.5 mock_entry2.hours = 1.5
mock_redmine.time_entry.filter.return_value = [mock_entry1, mock_entry2] mock_redmine.time_entry.filter.return_value = [mock_entry1, mock_entry2]
# Мок задачи
mock_issue = mock.MagicMock() mock_issue = mock.MagicMock()
mock_issue.id = 101 mock_issue.id = 101
mock_issue.project = "Проект X" mock_issue.project = "Проект X"
@@ -34,3 +45,124 @@ def test_fetch_issues_with_spent_time(mock_redmine_class):
assert len(result) == 1 assert len(result) == 1
issue, total_hours = result[0] issue, total_hours = result[0]
assert total_hours == 3.5 assert total_hours == 3.5
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_returns_none_when_no_entries(mock_redmine_class):
"""Нет time entries -- возвращается None."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_skips_entries_without_issue(mock_redmine_class):
"""Time entry без привязки к задаче игнорируется."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
# entry без issue атрибута
entry_no_issue = mock.MagicMock(spec=["hours"]) # нет .issue
entry_no_issue.hours = 1.0
mock_redmine.time_entry.filter.return_value = [entry_no_issue]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_multiple_issues(mock_redmine_class):
"""Несколько задач -- каждая с правильным суммарным временем."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
def make_entry(issue_id, hours):
e = mock.MagicMock()
e.issue.id = issue_id
e.hours = hours
return e
mock_redmine.time_entry.filter.return_value = [
make_entry(1, 1.0),
make_entry(2, 2.0),
make_entry(1, 0.5),
]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_issue2.project = "P"
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
assert len(result) == 2
hours_by_id = {issue.id: hours for issue, hours in result}
assert hours_by_id[1] == 1.5
assert hours_by_id[2] == 2.0
@mock.patch.dict(
os.environ,
{
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_API_KEY": "api-token",
"REDMINE_USER": "user",
"REDMINE_PASSWORD": "password",
},
clear=True,
)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_api_key_when_present(mock_redmine_class):
"""Если задан API key, он используется вместо логина/пароля."""
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 kwargs["key"] == "api-token"
assert kwargs["requests"] == {"verify": DEFAULT_REDMINE_VERIFY}
assert "username" not in kwargs
assert "password" not in kwargs
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
"""Если API key не задан, остаётся старая схема логин/пароль."""
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 kwargs["username"] == "user"
assert kwargs["password"] == "password"
assert kwargs["requests"] == {"verify": DEFAULT_REDMINE_VERIFY}
assert "key" not in kwargs
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_custom_verify_path(mock_redmine_class):
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 kwargs["requests"] == {"verify": "/tmp/redmine-ca.pem"}

View File

@@ -1,25 +1,101 @@
import os import os
import pytest
from unittest import mock from unittest import mock
from redmine_reporter.config import Config
import pytest
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY, Config
@mock.patch.dict(os.environ, { @mock.patch.dict(
os.environ,
{
"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_URL": "https://red.eltex.loc/",
"REDMINE_USER": "test", "REDMINE_USER": "test",
"REDMINE_PASSWORD": "secret" "REDMINE_PASSWORD": "secret",
}) },
def test_config_valid(): clear=True,
)
def test_config_valid_with_password_fallback():
Config.validate() # не должно быть исключения
@mock.patch.dict(
os.environ,
{
"REDMINE_URL": "https://red.eltex.loc/",
"REDMINE_API_KEY": "token",
},
clear=True,
)
def test_config_valid_with_api_key():
Config.validate() # не должно быть исключения Config.validate() # не должно быть исключения
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_config_missing(): def test_config_missing_url():
with pytest.raises(ValueError, match="REDMINE_URL"): with pytest.raises(ValueError, match="REDMINE_URL"):
Config.validate() Config.validate()
@mock.patch.dict(os.environ, {"REDMINE_AUTHOR": "Иванов И.И."}) @mock.patch.dict(os.environ, {"REDMINE_URL": "https://red.eltex.loc/"}, clear=True)
def test_config_missing_auth():
with pytest.raises(ValueError, match="REDMINE_API_KEY"):
Config.validate()
@mock.patch.dict(os.environ, {"REDMINE_URL": " https://red.eltex.loc/ "}, clear=True)
def test_get_redmine_url_strips_spaces_and_trailing_slash():
assert Config.get_redmine_url() == "https://red.eltex.loc"
@mock.patch.dict(os.environ, {"REDMINE_AUTHOR": " Иванов И.И. "}, clear=True)
def test_get_author(): def test_get_author():
assert Config.get_author("") == "Иванов И.И." assert Config.get_author("") == "Иванов И.И."
assert Config.get_author("Петров П.П.") == "Петров П.П." assert Config.get_author("Петров П.П.") == "Петров П.П."
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_author_fallback():
"""Если ни CLI, ни .env не задали автора -- возвращается пустая строка."""
assert Config.get_author("") == ""
@mock.patch.dict(
os.environ,
{
"DEFAULT_FROM_DATE": "2026-01-01",
"DEFAULT_TO_DATE": "2026-01-31",
},
clear=True,
)
def test_get_default_date_range_from_env():
assert Config.get_default_date_range() == "2026-01-01--2026-01-31"
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_default_date_range_fallback():
"""Если даты не заданы -- используется хардкод-заглушка."""
result = Config.get_default_date_range()
assert "--" in result # формат YYYY-MM-DD--YYYY-MM-DD
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_redmine_verify_default():
assert Config.get_redmine_verify() == DEFAULT_REDMINE_VERIFY
@pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"])
def test_get_redmine_verify_false_values(value):
with mock.patch.dict(os.environ, {"REDMINE_VERIFY": value}, clear=True):
assert Config.get_redmine_verify() is False
@pytest.mark.parametrize("value", ["1", "true", "True", "yes", "on"])
def test_get_redmine_verify_true_values(value):
with mock.patch.dict(os.environ, {"REDMINE_VERIFY": value}, clear=True):
assert Config.get_redmine_verify() is True
@mock.patch.dict(os.environ, {"REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True)
def test_get_redmine_verify_custom_path():
assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem"

View File

@@ -1,12 +1,24 @@
import pytest import io
import os
from typing import List from typing import List
from redmine_reporter.types import ReportRow from unittest import mock
from redmine_reporter.formatters.console import TableFormatter, CompactFormatter
import pytest
from odf.opendocument import OpenDocument, OpenDocumentText
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.html import HTMLFormatter
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 odf.opendocument import OpenDocument from redmine_reporter.types import ReportRow
def _make_empty_odt_bytes() -> bytes:
"""Создаёт минимальный валидный ODT-документ в памяти."""
doc = OpenDocumentText()
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
def make_fake_report_rows() -> List[ReportRow]: def make_fake_report_rows() -> List[ReportRow]:
@@ -16,9 +28,7 @@ def make_fake_report_rows() -> List[ReportRow]:
- Проект B: версия <N/A> (1 задача) - Проект B: версия <N/A> (1 задача)
- Проект C: версия v1.0 (1 задача), версия v1.1 (2 задачи) - Проект C: версия v1.0 (1 задача), версия v1.1 (2 задачи)
""" """
return [ return [
# Проект A, v1.0
{ {
"project": "Проект A", "project": "Проект A",
"version": "v1.0", "version": "v1.0",
@@ -27,7 +37,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 101, "issue_id": 101,
"subject": "Реализовать фичу X", "subject": "Реализовать фичу X",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "4ч 30м" "time_text": "4ч 30м",
}, },
{ {
"project": "Проект A", "project": "Проект A",
@@ -37,9 +47,8 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 102, "issue_id": 102,
"subject": "Исправить баг Y", "subject": "Исправить баг Y",
"status_ru": "Решена", "status_ru": "Решена",
"time_text": "" "time_text": "",
}, },
# Проект A, v2.0
{ {
"project": "Проект A", "project": "Проект A",
"version": "v2.0", "version": "v2.0",
@@ -48,9 +57,8 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 103, "issue_id": 103,
"subject": "Документация Z", "subject": "Документация Z",
"status_ru": "Ожидание", "status_ru": "Ожидание",
"time_text": "" "time_text": "",
}, },
# Проект B, без версии
{ {
"project": "Проект B", "project": "Проект B",
"version": "<N/A>", "version": "<N/A>",
@@ -59,9 +67,8 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 201, "issue_id": 201,
"subject": "Обновить README", "subject": "Обновить README",
"status_ru": "Закрыто", "status_ru": "Закрыто",
"time_text": "" "time_text": "",
}, },
# Проект C, v1.0
{ {
"project": "Проект C", "project": "Проект C",
"version": "v1.0", "version": "v1.0",
@@ -70,9 +77,8 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 301, "issue_id": 301,
"subject": "Настроить CI", "subject": "Настроить CI",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "3ч 15м" "time_text": "3ч 15м",
}, },
# Проект C, v1.1
{ {
"project": "Проект C", "project": "Проект C",
"version": "v1.1", "version": "v1.1",
@@ -81,7 +87,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 302, "issue_id": 302,
"subject": "Добавить тесты", "subject": "Добавить тесты",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "" "time_text": "",
}, },
{ {
"project": "Проект C", "project": "Проект C",
@@ -91,7 +97,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 303, "issue_id": 303,
"subject": "Рефакторинг", "subject": "Рефакторинг",
"status_ru": "Решена", "status_ru": "Решена",
"time_text": "6ч 45м" "time_text": "6ч 45м",
}, },
] ]
@@ -101,75 +107,136 @@ def fake_rows():
return make_fake_report_rows() return make_fake_report_rows()
def _get_formatter_output_text(result): @pytest.fixture
"""Преобразует результат форматтера в строку для проверки содержимого.""" def odt_formatter():
"""ODTFormatter с замоканной загрузкой шаблона."""
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)
mock_path = mock.MagicMock()
mock_path.open.return_value = mock_file
if isinstance(result, OpenDocument): with mock.patch(
return "" "redmine_reporter.formatters.odt.resources.files",
elif isinstance(result, str): return_value=mock.MagicMock(
return result joinpath=mock.MagicMock(
else: return_value=mock.MagicMock(open=mock.MagicMock(return_value=mock_file))
raise TypeError(f"Unexpected formatter output type: {type(result)}") )
),
):
yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31")
FORMATTER_FACTORIES = [ # -- Параметризованные тесты текстовых форматтеров --
TEXT_FORMATTER_FACTORIES = [
("table", lambda: TableFormatter()), ("table", lambda: TableFormatter()),
("compact", lambda: CompactFormatter()), ("compact", lambda: CompactFormatter()),
("csv", lambda: CSVFormatter()), ("csv", lambda: CSVFormatter()),
("markdown", lambda: MarkdownFormatter()), ("markdown", lambda: MarkdownFormatter()),
("odt", lambda: ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31")),
] ]
@pytest.mark.parametrize("name, formatter_factory", FORMATTER_FACTORIES) @pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES)
def test_formatter_does_not_crash(fake_rows, name, formatter_factory): def test_text_formatter_returns_nonempty_string(fake_rows, name, factory):
"""Проверяем, что форматтер не падает на валидных данных.""" """Текстовые форматтеры возвращают непустую строку."""
result = factory().format(fake_rows)
formatter = formatter_factory()
result = formatter.format(fake_rows)
if name == "odt":
assert isinstance(result, OpenDocument)
else:
assert isinstance(result, str) assert isinstance(result, str)
assert len(result.strip()) > 0 assert len(result.strip()) > 0
@pytest.mark.parametrize("name, formatter_factory", FORMATTER_FACTORIES) @pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES)
def test_formatter_contains_key_content(fake_rows, name, formatter_factory): def test_text_formatter_contains_key_content(fake_rows, name, factory):
"""Проверяем, что вывод содержит ключевые элементы.""" """Вывод содержит ключевые данные из отчёта."""
output = factory().format(fake_rows)
formatter = formatter_factory() assert "Проект A" in output
result = formatter.format(fake_rows) assert "Проект B" in output
assert "В работе" in output
assert "<N/A>" in output
assert "6ч 45м" in output
output_text = _get_formatter_output_text(result)
if not output_text:
return # Пропускаем ODT
# Общие элементы
assert "Проект A" in output_text
assert "Проект B" in output_text
assert "В работе" in output_text
assert "<N/A>" in output_text
assert "6ч 45м" in output_text
# Специфика по форматам
if name == "csv": if name == "csv":
# В CSV ID и subject отдельные колонки # В CSV issue_id и subject -- отдельные колонки
assert "101" in output_text assert "101" in output
assert "Реализовать фичу X" in output_text assert "Реализовать фичу X" in output
else: else:
# В остальных — вместе assert "101. Реализовать фичу X" in output
assert "101. Реализовать фичу X" in output_text
def test_odt_save_creates_valid_file(fake_rows, tmp_path): @pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES)
"""Проверяем, что ODT можно сохранить и он открывается как ZIP.""" def test_text_formatter_empty_rows(name, factory):
"""Форматтер не падает на пустом списке строк."""
result = factory().format([])
assert isinstance(result, str)
output_file = tmp_path / "report.odt"
# -- Тесты консольных форматтеров --
def test_table_formatter_save_raises(fake_rows):
with pytest.raises(NotImplementedError):
TableFormatter().save(fake_rows, "/dev/null")
def test_compact_formatter_save_raises(fake_rows):
with pytest.raises(NotImplementedError):
CompactFormatter().save(fake_rows, "/dev/null")
def test_markdown_formatter_escapes_table_cells():
rows = make_fake_report_rows()
rows[0]["project"] = "A|B"
rows[0]["display_project"] = "A|B"
rows[0]["subject"] = "Fix | split\nline"
output = MarkdownFormatter().format(rows)
assert "A\\|B" in output
assert "101. Fix \\| split<br>line" in output
def test_html_formatter_escapes_cells():
rows = make_fake_report_rows()
rows[0]["project"] = 'A&B "<Project>"'
rows[0]["display_project"] = rows[0]["project"]
rows[0]["subject"] = "Fix <tag> & attrs"
output = HTMLFormatter().format(rows)
assert "A&amp;B &quot;&lt;Project&gt;&quot;" in output
assert "101. Fix &lt;tag&gt; &amp; attrs" in output
assert "Fix <tag>" not in output
# -- Тесты ODT форматтера --
def test_odt_formatter_returns_opendocument(fake_rows, odt_formatter):
"""ODTFormatter.format() возвращает объект OpenDocument."""
result = odt_formatter.format(fake_rows)
assert isinstance(result, OpenDocument)
def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
"""ODT можно сохранить -- файл валиден (сигнатура ZIP)."""
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") formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
output_file = tmp_path / "report.odt"
formatter.save(fake_rows, str(output_file)) formatter.save(fake_rows, str(output_file))
assert output_file.exists() assert output_file.exists()
with open(output_file, "rb") as f: assert output_file.read_bytes()[:2] == b"PK" # сигнатура ZIP
assert f.read(2) == b"PK" # сигнатура ZIP

View File

@@ -1,6 +1,4 @@
import pytest from redmine_reporter.report_builder import STATUS_TRANSLATION, build_grouped_report
from redmine_reporter.report_builder import build_grouped_report, STATUS_TRANSLATION
from redmine_reporter.utils import get_version
class MockIssue: class MockIssue:
@@ -9,18 +7,35 @@ class MockIssue:
self.project = project self.project = project
self.subject = subject self.subject = subject
self.status = status self.status = status
if fixed_version is not None: if fixed_version is not None:
self.fixed_version = fixed_version self.fixed_version = fixed_version
# -- Таблица переводов статусов --
def test_status_translation(): def test_status_translation():
assert STATUS_TRANSLATION["Closed"] == "Закрыто" assert STATUS_TRANSLATION["Closed"] == "Закрыто"
assert STATUS_TRANSLATION["New"] == "В работе" assert STATUS_TRANSLATION["New"] == "В работе"
assert STATUS_TRANSLATION["Resolved"] == "Решена" assert STATUS_TRANSLATION["Resolved"] == "Решена"
assert STATUS_TRANSLATION["Pending"] == "Ожидание"
def test_build_grouped_report(): def test_status_translation_unknown_passthrough():
"""Неизвестный статус возвращается как есть."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
assert "SomeNewStatus" not in STATUS_TRANSLATION
# build_grouped_report вернёт оригинальное значение
issue = MockIssue("P", "S", "SomeNewStatus", "v1.0", 1)
rows = build_grouped_report([(issue, 1.0)])
assert rows[0]["status_ru"] == "SomeNewStatus"
# -- Основная логика группировки --
def test_build_grouped_report_grouping():
issues = [ issues = [
(MockIssue("Камеры", "Фича A", "New", "v2.5.0", 101), 2.0), (MockIssue("Камеры", "Фича A", "New", "v2.5.0", 101), 2.0),
(MockIssue("Камеры", "Баг B", "Resolved", "v2.5.0", 102), 1.5), (MockIssue("Камеры", "Баг B", "Resolved", "v2.5.0", 102), 1.5),
@@ -29,17 +44,64 @@ def test_build_grouped_report():
rows = build_grouped_report(issues) rows = build_grouped_report(issues)
assert len(rows) == 3 assert len(rows) == 3
# Первая строка полное название проекта и версии # Первая строка -- полное название проекта и версии
assert rows[0]["display_project"] == "Камеры" assert rows[0]["display_project"] == "Камеры"
assert rows[0]["display_version"] == "v2.5.0" assert rows[0]["display_version"] == "v2.5.0"
# Вторая пустые display_* из-за совпадения # Вторая -- пустые display_* из-за совпадения проекта+версии
assert rows[1]["display_project"] == "" assert rows[1]["display_project"] == ""
assert rows[1]["display_version"] == "" assert rows[1]["display_version"] == ""
# Третья новый проект # Третья -- новый проект
assert rows[2]["display_project"] == "ПО" assert rows[2]["display_project"] == "ПО"
assert rows[2]["display_version"] == "<N/A>" assert rows[2]["display_version"] == "<N/A>"
# Проверка перевода и времени
assert rows[0]["status_ru"] == "В работе" assert rows[0]["status_ru"] == "В работе"
assert rows[0]["time_text"] == "" assert rows[0]["time_text"] == ""
assert rows[1]["time_text"] == "1ч 30м" assert rows[1]["time_text"] == "1ч 30м"
def test_build_grouped_report_new_version_same_project():
"""Смена версии внутри одного проекта -- display_project пустой, display_version новая."""
issues = [
(MockIssue("Камеры", "Задача 1", "New", "v1.0", 1), 1.0),
(MockIssue("Камеры", "Задача 2", "New", "v2.0", 2), 1.0),
]
rows = build_grouped_report(issues)
assert rows[0]["display_project"] == "Камеры"
assert rows[0]["display_version"] == "v1.0"
assert rows[1]["display_project"] == ""
assert rows[1]["display_version"] == "v2.0"
def test_build_grouped_report_sorts_input():
"""Несортированный вход -- результат всё равно корректно сгруппирован."""
issues = [
(MockIssue("ПО", "Задача B", "New", "v1.0", 2), 1.0),
(MockIssue("Камеры", "Задача A", "New", "v1.0", 1), 2.0),
]
rows = build_grouped_report(issues)
# После сортировки "Камеры" < "ПО" (лексикографически по кириллице)
assert rows[0]["project"] == "Камеры"
assert rows[1]["project"] == "ПО"
# Оба display_project непустые -- разные проекты
assert rows[0]["display_project"] == "Камеры"
assert rows[1]["display_project"] == "ПО"
def test_build_grouped_report_empty():
"""Пустой вход -- пустой результат, без исключений."""
rows = build_grouped_report([])
assert rows == []
def test_build_grouped_report_no_time():
"""fill_time=False -- time_text пустой для всех строк."""
issues = [(MockIssue("P", "S", "New", "v1.0", 1), 3.5)]
rows = build_grouped_report(issues, fill_time=False)
assert rows[0]["time_text"] == ""
def test_build_grouped_report_preserves_issue_id_and_subject():
issues = [(MockIssue("P", "Моя задача", "Closed", "v1.0", 42), 0.5)]
rows = build_grouped_report(issues)
assert rows[0]["issue_id"] == 42
assert rows[0]["subject"] == "Моя задача"

View File

@@ -1,27 +1,80 @@
import pytest from redmine_reporter.utils import (
from redmine_reporter.utils import hours_to_human, get_month_name_from_range, get_version get_month_name_from_range,
get_version,
hours_to_human,
)
def test_hours_to_human(): def test_hours_to_human_zero():
assert hours_to_human(0) == "" assert hours_to_human(0) == ""
assert hours_to_human(-1) == ""
def test_hours_to_human_whole_hours():
assert hours_to_human(1.0) == "" assert hours_to_human(1.0) == ""
assert hours_to_human(2.5) == "2ч 30м" assert hours_to_human(8.0) == ""
def test_hours_to_human_minutes_only():
assert hours_to_human(0.75) == "45м" assert hours_to_human(0.75) == "45м"
assert hours_to_human(3.1666) == "3ч 1" # ≈ 3ч 10м assert hours_to_human(0.5) == "30м"
def test_hours_to_human_mixed():
assert hours_to_human(2.5) == "2ч 30м"
assert hours_to_human(1.5) == "1ч 30м"
def test_hours_to_human_rounding():
assert hours_to_human(3.1666) == "3ч 10м" # 190 минут -> 3ч 10м
def test_get_month_name_from_range(): def test_get_month_name_from_range():
assert get_month_name_from_range("2026-01-01", "2026-01-31") == "Январь" assert get_month_name_from_range("2026-01-01", "2026-01-31") == "Январь"
assert get_month_name_from_range("2025-12-01", "2026-02-15") == "Февраль" # берётся to_date assert get_month_name_from_range("2025-12-01", "2026-02-15") == "Февраль"
assert get_month_name_from_range("invalid", "also_invalid") == "Январь" # fallback
def test_get_version(): def test_get_month_name_from_range_all_months():
months = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
]
for i, name in enumerate(months, start=1):
to_date = f"2026-{i:02d}-01"
assert get_month_name_from_range("2026-01-01", to_date) == name
def test_get_month_name_from_range_invalid_fallback():
"""Невалидная дата -- возвращается 'Январь'."""
assert get_month_name_from_range("invalid", "also_invalid") == "Январь"
def test_get_version_with_attribute():
class MockIssue:
fixed_version = "v2.5.0"
assert get_version(MockIssue()) == "v2.5.0"
def test_get_version_without_attribute():
class MockIssue: class MockIssue:
pass pass
issue_with = MockIssue()
issue_with.fixed_version = "v2.5.0"
assert get_version(issue_with) == "v2.5.0"
issue_without = MockIssue() assert get_version(MockIssue()) == "<N/A>"
assert get_version(issue_without) == "<N/A>"
def test_get_version_none_attribute():
class MockIssue:
fixed_version = None
assert get_version(MockIssue()) == "<N/A>"