Tighten configuration and export handling

This commit is contained in:
Кокос Артем Николаевич
2026-05-22 17:41:56 +07:00
parent 8bc8181ce3
commit 2db0ab1f0b
20 changed files with 423 additions and 350 deletions

220
README.md
View File

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

View File

@@ -51,3 +51,10 @@ target-version = ['py39']
[tool.isort]
profile = "black"
multi_line_output = 3
[tool.mypy]
warn_unused_configs = true
[[tool.mypy.overrides]]
module = ["odf.*", "redminelib.*", "tabulate"]
ignore_missing_imports = true

View File

@@ -1,12 +1,14 @@
import os
import sys
import argparse
import os
import re
import sys
from datetime import datetime
from typing import List, Optional
from .config import Config
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 .formatters.factory import get_formatter_by_extension, get_console_formatter
def parse_date_range(date_arg: str) -> tuple[str, str]:
@@ -15,7 +17,22 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
parts = date_arg.split("--", 1)
if len(parts) != 2:
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:
@@ -36,7 +53,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
parser.add_argument(
"--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(
"--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"

View File

@@ -1,17 +1,21 @@
from typing import Any, Dict, List, Optional, Tuple
from redminelib import Redmine
from redminelib.resources import Issue
from .config import Config
from .utils import get_version
REQUESTS_OPTIONS = {"verify": "/etc/ssl/certs/ca-certificates.crt"}
def _get_redmine_auth_kwargs() -> Dict[str, Any]:
"""Return Redmine auth kwargs. API key has priority over legacy password auth."""
if Config.REDMINE_API_KEY:
return {"key": Config.REDMINE_API_KEY}
return {"username": Config.REDMINE_USER, "password": Config.REDMINE_PASSWORD}
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(
@@ -24,9 +28,9 @@ def fetch_issues_with_spent_time(
"""
redmine = Redmine(
Config.REDMINE_URL,
Config.get_redmine_url(),
**_get_redmine_auth_kwargs(),
requests=REQUESTS_OPTIONS,
requests={"verify": Config.get_redmine_verify()},
)
current_user = redmine.user.get("current")
@@ -48,9 +52,7 @@ def fetch_issues_with_spent_time(
# Загружаем полные объекты задач
issue_list_str = ",".join(str(i) for i in issue_ids)
issues = redmine.issue.filter(
issue_id=issue_list_str, status_id="*", sort="project:asc"
)
issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc")
# Сопоставляем задачи с суммарным временем
result = []

View File

@@ -1,41 +1,68 @@
import os
from typing import Union
from dotenv import 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:
REDMINE_URL = os.getenv("REDMINE_URL", "").strip().rstrip("/")
REDMINE_API_KEY = os.getenv("REDMINE_API_KEY")
REDMINE_USER = os.getenv("REDMINE_USER")
REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD")
REDMINE_AUTHOR = os.getenv("REDMINE_AUTHOR")
DEFAULT_FROM_DATE = os.getenv("DEFAULT_FROM_DATE")
DEFAULT_TO_DATE = os.getenv("DEFAULT_TO_DATE")
@classmethod
def get_redmine_url(cls) -> str:
return os.getenv("REDMINE_URL", "").strip().rstrip("/")
@classmethod
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
def get_author(cls, cli_author: str = "") -> str:
"""Возвращает автора: из CLI если задан, иначе из .env, иначе — заглушку."""
if cli_author:
return cli_author
if cls.REDMINE_AUTHOR:
return cls.REDMINE_AUTHOR
return ""
return os.getenv("REDMINE_AUTHOR", "").strip()
@classmethod
def get_default_date_range(cls) -> str:
if cls.DEFAULT_FROM_DATE and cls.DEFAULT_TO_DATE:
return f"{cls.DEFAULT_FROM_DATE}--{cls.DEFAULT_TO_DATE}"
default_from_date = os.getenv("DEFAULT_FROM_DATE", "").strip()
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
return "2025-12-19--2026-01-31"
@classmethod
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)")
if cls.REDMINE_API_KEY:
if cls.get_redmine_api_key():
return
if not (cls.REDMINE_USER and cls.REDMINE_PASSWORD):
if not (cls.get_redmine_user() and cls.get_redmine_password()):
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 typing import Any, List
from ..types import ReportRow

View File

@@ -1,7 +1,9 @@
from typing import List
from tabulate import tabulate
from .base import Formatter
from ..types import ReportRow
from .base import Formatter
class TableFormatter(Formatter):

View File

@@ -1,8 +1,9 @@
import csv
import io
from typing import List
from .base import Formatter
from ..types import ReportRow
from .base import Formatter
class CSVFormatter(Formatter):
@@ -14,9 +15,7 @@ class CSVFormatter(Formatter):
def format(self, rows: List[ReportRow]) -> str:
output = io.StringIO()
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:
writer.writerow(
[

View File

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

View File

@@ -1,6 +1,8 @@
from html import escape
from typing import Dict, List
from .base import Formatter
from ..types import ReportRow
from .base import Formatter
class HTMLFormatter(Formatter):
@@ -36,34 +38,38 @@ class HTMLFormatter(Formatter):
]
for project, versions in projects.items():
project_text = escape(project)
total_project_rows = sum(len(tasks) for tasks in versions.values())
first_version_in_project = True
for version, task_rows in versions.items():
version_text = escape(version)
row_span_version = len(task_rows)
first_row_in_version = True
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>")
# Ячейка "Проект" - только в первой строке проекта
if first_version_in_project and first_row_in_version:
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:
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
# Остальные колонки
task_cell = f"{r['issue_id']}. {r['subject']}"
lines.append(f" <td>{task_cell}</td>")
lines.append(f" <td>{r['status_ru']}</td>")
lines.append(f" <td>{r['time_text']}</td>")
lines.append(f" <td>{status_text}</td>")
lines.append(f" <td>{time_text}</td>")
lines.append(" </tr>")

View File

@@ -1,6 +1,11 @@
from typing import List
from .base import Formatter
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):
@@ -15,10 +20,13 @@ class MarkdownFormatter(Formatter):
"|--------|--------|--------|--------|-----------|",
]
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(
f"| {r['display_project']} | {r['display_version']} "
f"| {task_cell} | {r['status_ru']} | {r['time_text']} |"
f"| {_escape_markdown_table_cell(r['display_project'])} "
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)

View File

@@ -1,12 +1,14 @@
from importlib import resources
from typing import List
from typing import Dict, List
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.table import Table, TableColumn, TableRow, TableCell
from odf.style import Style, TableColumnProperties, TableCellProperties
from .base import Formatter
from ..types import ReportRow
from ..utils import get_month_name_from_range
from .base import Formatter
class ODTFormatter(Formatter):
@@ -25,11 +27,7 @@ class ODTFormatter(Formatter):
Форматирует данные в объект 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)
para_style_name = "Standard"
@@ -43,9 +41,7 @@ class ODTFormatter(Formatter):
# Стиль ячеек
cell_style_name = "TableCellStyle"
cell_style = Style(name=cell_style_name, family="table-cell")
cell_props = TableCellProperties(
padding="0.04in", border="0.05pt solid #000000"
)
cell_props = TableCellProperties(padding="0.04in", border="0.05pt solid #000000")
cell_style.addElement(cell_props)
doc.automaticstyles.addElement(cell_style)
@@ -73,7 +69,7 @@ class ODTFormatter(Formatter):
header_row.addElement(cell)
table.addElement(header_row)
projects = {}
projects: Dict[str, Dict[str, List[ReportRow]]] = {}
for r in rows:
project = r["project"]
version = r["version"]
@@ -103,9 +99,7 @@ class ODTFormatter(Formatter):
# Ячейка "Проект" - только в первой строке всего проекта
if first_version_in_project and first_row_in_version:
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)
cell_project.addElement(p)
row.addElement(cell_project)
@@ -113,9 +107,7 @@ class ODTFormatter(Formatter):
# Ячейка "Версия" - только в первой строке каждой версии
if first_row_in_version:
cell_version = TableCell(stylename=cell_style_name)
cell_version.setAttribute(
"numberrowsspanned", str(row_span_version)
)
cell_version.setAttribute("numberrowsspanned", str(row_span_version))
p = P(stylename=para_style_name, text=version)
cell_version.addElement(p)
row.addElement(cell_version)

View File

@@ -1,5 +1,7 @@
from typing import List, Tuple, cast
from redminelib.resources import Issue
from .types import ReportRow
from .utils import get_version, hours_to_human
@@ -33,9 +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])))
rows: List[ReportRow] = []
prev_project: str = ""
@@ -49,9 +49,7 @@ def build_grouped_report(
time_text = hours_to_human(hours) if fill_time else ""
display_project = project if project != prev_project else ""
display_version = (
version if (project != prev_project or version != prev_version) else ""
)
display_version = version if (project != prev_project or version != prev_version) else ""
rows.append(
cast(

View File

@@ -30,7 +30,10 @@ def get_month_name_from_range(from_date: str, to_date: str) -> str:
def get_version(issue) -> str:
"""Возвращает версию задачи или '<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:

View File

@@ -1,22 +1,44 @@
import os
import sys
from io import StringIO
from unittest import mock
from redmine_reporter.cli import main
from redmine_reporter.config import Config
import pytest
from redmine_reporter.cli import main, parse_date_range
VALID_ENV = {
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_API_KEY": "token",
}
# Config читает env при импорте -- патчим класс напрямую.
# fetch_issues_with_spent_time импортируется в cli.py через "from .client import ...",
# поэтому мокать нужно имя в модуле cli, а не в client.
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="x",
REDMINE_PASSWORD="y",
@pytest.mark.parametrize(
"date_arg, expected",
[
("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")),
(" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")),
],
)
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 задачах."""
@@ -31,13 +53,7 @@ def test_cli_smoke_empty(mock_fetch):
assert "Total issues: 0" in captured.getvalue()
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="x",
REDMINE_PASSWORD="y",
)
@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."""
@@ -46,32 +62,21 @@ def test_cli_returns_zero_on_no_entries(mock_fetch):
assert code == 0
@mock.patch.multiple(
Config,
REDMINE_URL="",
REDMINE_API_KEY=None,
REDMINE_USER=None,
REDMINE_PASSWORD=None,
)
@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.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="x",
REDMINE_PASSWORD="y",
)
@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."""
@@ -81,13 +86,7 @@ def test_cli_unknown_output_extension(mock_fetch, tmp_path):
assert code == 1
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="x",
REDMINE_PASSWORD="y",
)
@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 с подсказкой."""

View File

@@ -1,22 +1,28 @@
import os
from unittest import mock
from redmine_reporter.client import fetch_issues_with_spent_time
from redmine_reporter.config import Config
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
PASSWORD_ENV = {
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_USER": "user",
"REDMINE_PASSWORD": "password",
}
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="user",
REDMINE_PASSWORD="password",
)
def _configure_current_user(mock_redmine, user_id=1):
mock_user = mock.MagicMock()
mock_user.id = user_id
mock_redmine.user.get.return_value = mock_user
@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
mock_user = mock.MagicMock()
mock_user.id = 123
mock_redmine.user.get.return_value = mock_user
_configure_current_user(mock_redmine, user_id=123)
mock_entry1 = mock.MagicMock()
mock_entry1.issue.id = 101
@@ -41,40 +47,24 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
assert total_hours == 3.5
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="user",
REDMINE_PASSWORD="password",
)
@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
mock_user = mock.MagicMock()
mock_user.id = 1
mock_redmine.user.get.return_value = mock_user
_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.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="user",
REDMINE_PASSWORD="password",
)
@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
mock_user = mock.MagicMock()
mock_user.id = 1
mock_redmine.user.get.return_value = mock_user
_configure_current_user(mock_redmine)
# entry без issue атрибута
entry_no_issue = mock.MagicMock(spec=["hours"]) # нет .issue
@@ -86,20 +76,12 @@ def test_fetch_skips_entries_without_issue(mock_redmine_class):
assert result is None
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="user",
REDMINE_PASSWORD="password",
)
@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
mock_user = mock.MagicMock()
mock_user.id = 1
mock_redmine.user.get.return_value = mock_user
_configure_current_user(mock_redmine)
def make_entry(issue_id, hours):
e = mock.MagicMock()
@@ -130,44 +112,38 @@ def test_fetch_multiple_issues(mock_redmine_class):
assert hours_by_id[2] == 2.0
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY="api-token",
REDMINE_USER="user",
REDMINE_PASSWORD="password",
@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
mock_user = mock.MagicMock()
mock_user.id = 1
mock_redmine.user.get.return_value = mock_user
_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.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="user",
REDMINE_PASSWORD="password",
)
@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
mock_user = mock.MagicMock()
mock_user.id = 1
mock_redmine.user.get.return_value = mock_user
_configure_current_user(mock_redmine)
mock_redmine.time_entry.filter.return_value = []
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@@ -175,4 +151,18 @@ def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
_, 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,81 +1,101 @@
import pytest
import os
from unittest import mock
from redmine_reporter.config import Config
# Config читает os.getenv() в момент определения класса (class-level атрибуты),
# поэтому mock.patch.dict(os.environ) не помогает -- класс уже загружен.
# Правильный способ -- патчить атрибуты самого класса.
import pytest
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY, Config
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="test",
REDMINE_PASSWORD="secret",
@mock.patch.dict(
os.environ,
{
"REDMINE_URL": "https://red.eltex.loc/",
"REDMINE_USER": "test",
"REDMINE_PASSWORD": "secret",
},
clear=True,
)
def test_config_valid_with_password_fallback():
Config.validate() # не должно быть исключения
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY="token",
REDMINE_USER=None,
REDMINE_PASSWORD=None,
@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() # не должно быть исключения
@mock.patch.multiple(
Config,
REDMINE_URL="",
REDMINE_API_KEY=None,
REDMINE_USER=None,
REDMINE_PASSWORD=None,
)
@mock.patch.dict(os.environ, {}, clear=True)
def test_config_missing_url():
with pytest.raises(ValueError, match="REDMINE_URL"):
Config.validate()
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER=None,
REDMINE_PASSWORD=None,
)
@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.multiple(Config, REDMINE_AUTHOR="Иванов И.И.")
@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():
assert Config.get_author("") == "Иванов И.И."
assert Config.get_author("Петров П.П.") == "Петров П.П."
@mock.patch.multiple(Config, REDMINE_AUTHOR=None)
@mock.patch.dict(os.environ, {}, clear=True)
def test_get_author_fallback():
"""Если ни CLI, ни .env не задали автора -- возвращается пустая строка."""
assert Config.get_author("") == ""
@mock.patch.multiple(
Config,
DEFAULT_FROM_DATE="2026-01-01",
DEFAULT_TO_DATE="2026-01-31",
@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.multiple(Config, DEFAULT_FROM_DATE=None, DEFAULT_TO_DATE=None)
@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,13 +1,16 @@
import pytest
import io
from typing import List
from unittest import mock
from redmine_reporter.types import ReportRow
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.html import HTMLFormatter
from redmine_reporter.formatters.markdown import MarkdownFormatter
from redmine_reporter.formatters.odt import ODTFormatter
from odf.opendocument import OpenDocument, OpenDocumentText
from redmine_reporter.types import ReportRow
def _make_empty_odt_bytes() -> bytes:
@@ -122,9 +125,7 @@ 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")
# -- Параметризованные тесты текстовых форматтеров --
@@ -184,6 +185,31 @@ def test_compact_formatter_save_raises(fake_rows):
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 форматтера --
@@ -208,9 +234,7 @@ def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
)
),
):
formatter = ODTFormatter(
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
)
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
output_file = tmp_path / "report.odt"
formatter.save(fake_rows, str(output_file))

View File

@@ -1,4 +1,4 @@
from redmine_reporter.report_builder import build_grouped_report, STATUS_TRANSLATION
from redmine_reporter.report_builder import STATUS_TRANSLATION, build_grouped_report
class MockIssue:

View File

@@ -1,7 +1,7 @@
from redmine_reporter.utils import (
hours_to_human,
get_month_name_from_range,
get_version,
hours_to_human,
)
@@ -74,10 +74,7 @@ def test_get_version_without_attribute():
def test_get_version_none_attribute():
"""fixed_version = None -- str(None) == 'None', не '<N/A>'."""
class MockIssue:
fixed_version = None
# get_version возвращает str(getattr(...)), None задан явно -> "None"
assert get_version(MockIssue()) == "None"
assert get_version(MockIssue()) == "<N/A>"