33 Commits
v0.1.1 ... main

Author SHA1 Message Date
Кокос Артем Николаевич
06cd57e2c4 Blacked 2026-02-05 15:31:31 +07:00
Кокос Артем Николаевич
d7e927e6eb Bump version: v1.4.1 2026-02-05 15:18:40 +07:00
Кокос Артем Николаевич
000bf37503 Update status map 2026-02-05 15:15:44 +07:00
Артём Кокос
dfb8d474b4 Update README.md 2026-01-26 21:03:24 +07:00
Кокос Артем Николаевич
b7f03666dc Bump version: v1.4.0 2026-01-26 13:43:54 +07:00
Кокос Артем Николаевич
30310d614d Add HTML formatter
Closes #7
2026-01-26 12:57:33 +07:00
Кокос Артем Николаевич
ad62ef4f6c Fix CSV & MD 2026-01-26 12:56:57 +07:00
Кокос Артем Николаевич
355849e004 Fix issue mock for tests 2026-01-26 12:37:57 +07:00
Артём Кокос
d839be8776 Add unit-tests 2026-01-25 12:40:23 +07:00
Артём Кокос
ea90fe79d0 ODT template into resources 2026-01-25 12:24:32 +07:00
Артём Кокос
1f77088c21 Add tests for all formatters 2026-01-25 11:19:04 +07:00
Артём Кокос
8278864b01 Formatters factory 2026-01-25 11:01:34 +07:00
Кокос Артем Николаевич
f858618a13 Bump version: 1.3.0 2026-01-24 16:10:03 +07:00
Кокос Артем Николаевич
e344715f61 feat(formatter): unify data pipeline with ReportRow and report_builder 2026-01-24 16:09:25 +07:00
Кокос Артем Николаевич
245ea0a3fa Add Markdown format
Closes #6
2026-01-22 16:52:18 +07:00
Кокос Артем Николаевич
2a39de467f Add CSV format support
Closes #5
2026-01-22 16:42:39 +07:00
Кокос Артем Николаевич
6416df481e Optional disable fill time for console output too 2026-01-22 12:47:31 +07:00
Кокос Артем Николаевич
ead6c72d16 Optional disable fill time column 2026-01-22 12:38:06 +07:00
Кокос Артем Николаевич
e7efda232c Add helper text into ODT output 2026-01-22 12:07:43 +07:00
Кокос Артем Николаевич
937885a12b Bump version: 1.2.0 2026-01-22 10:59:48 +07:00
Кокос Артем Николаевич
932dd1198a Update .gitignore: ignore report.odt always 2026-01-22 10:58:42 +07:00
Кокос Артем Николаевич
0bff2363dc Add empty line after title 2026-01-22 10:55:42 +07:00
Кокос Артем Николаевич
9b260b27fd Use right cells style in ODT table 2026-01-22 10:45:37 +07:00
Кокос Артем Николаевич
a8511368ce Fix cells width 2026-01-21 18:02:00 +07:00
Кокос Артем Николаевич
7a8b629c7c Bump version: 1.1.0 2026-01-21 14:23:06 +07:00
Кокос Артем Николаевич
41c7ef24a3 Update README.md 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
5b813c76e9 Use last month in ODT-report 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
5a5ee00726 Config: Report author name 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
4a5dee7a14 Update template with table-styles 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
2c123e9ae7 ODT: Group by project & versions 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
9a2f753480 Album orient by using templ. doc 2026-01-21 14:22:18 +07:00
Artem Kokos
bca24189c7 ODT table support 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
6fcc834617 Add lic 2026-01-21 10:44:53 +07:00
26 changed files with 1045 additions and 154 deletions

7
.gitignore vendored
View File

@@ -85,3 +85,10 @@ secrets.json
# Temporary files
*.tmp
*.bak
# Just in case
.~*
report.odt
report.csv
report.md
*.html

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Artem Kokos
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,23 +1,26 @@
# redmine-reporter
Инструмент для генерации отчётов по задачам в Redmine на основе ваших записей о затраченном времени.
Инструмент для генерации отчётов по задачам в Redmine на основе ваших записей о затраченном времени.
> Предназначен для внутреннего использования в Eltex. Работает с `https://red.eltex.loc/`.
📄 **Лицензия**: [MIT](./LICENSE) — делайте что угодно.
---
## 🔧 Возможности
- Безопасная передача учётных данных через переменные окружения или `.env`
- Два режима вывода: компактный (для копирования) и табличный (для просмотра)
- Группировка задач по проекту и версии
- Перевод статусов на русский язык
- Поддержка нескольких форматов экспорта: **ODT**, **CSV**, **Markdown**, **HTML**
- Два режима вывода в консоль: табличный (красивая таблица) и компактный (для копирования)
- Перевод статусов задач на русский язык
- Автоматическое определение месяца отчёта по дате окончания периода (для **ODT**)
- Простой CLI с понятными аргументами
- Поддержка настройки диапазона дат по умолчанию через `.env`
---
## 🚀 Установка и настройка (продакшен)
## 🚀 Установка и настройка
### 1. Клонируйте репозиторий
@@ -26,7 +29,7 @@ git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git
cd redmine-reporter
```
### 2. Создайте изолированное окружение и установите зависимости
### 2. Создайте виртуальное окружение и установите зависимости
```bash
python3 -m venv .venv
@@ -49,7 +52,7 @@ cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi)
> ✅ Это безопасно: вы просто добавляете доверенные системные сертификаты к Python.
> Не используйте `verify=False` — это создаёт уязвимость.
### 4. Настройте учётные данные и (опционально) даты
### 4. Настройте учётные данные
Создайте файл `.env` в корне проекта (**никогда не коммитьте его!**):
@@ -57,6 +60,7 @@ cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi)
REDMINE_URL=https://red.eltex.loc/
REDMINE_USER=ваш.логин
REDMINE_PASSWORD=ваш_пароль
REDMINE_AUTHOR=Иванов Иван Иванович
# Опционально: диапазон дат по умолчанию
DEFAULT_FROM_DATE=2026-01-01
@@ -69,12 +73,10 @@ DEFAULT_TO_DATE=2026-01-31
export REDMINE_URL=https://red.eltex.loc/
export REDMINE_USER=ваш.логин
export REDMINE_PASSWORD=...
export DEFAULT_FROM_DATE=2026-01-01
export DEFAULT_TO_DATE=2026-01-31
export REDMINE_AUTHOR="Иванов Иван Иванович"
```
> 🔐 Рекомендуется использовать аккаунт с минимальными правами (только чтение time entries и задач).
> 💡 Если `DEFAULT_FROM_DATE` и `DEFAULT_TO_DATE` не заданы, используется встроенный диапазон: `2025-12-19--2026-01-31`.
---
@@ -92,22 +94,52 @@ source .venv/bin/activate
# Отчёт за период по умолчанию (из .env или встроенный)
redmine-reporter
# Отчёт за произвольный период (переопределяет .env)
# Отчёт за произвольный период
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_USER=ivanov.ivan
REDMINE_PASSWORD=supersecret
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.00h
│ │ │ 12346. Исправить утечку памяти │ Решена │ 2.00h
│ ПО │ <N/A> │ 12350. Обновить документацию │ Ожидание │ 12.00h
│ Камеры │ v2.5.0 │ 12345. Поддержка нового датчика │ В работе │ 2ч 30м
│ │ │ 12346. Исправить утечку памяти │ Решена │ 2ч
│ ПО │ <N/A> │ 12350. Обновить документацию │ Ожидание │ 12ч
╘════════════╧═══════════╧══════════════════════════════════════╧═══════════╧════════════╛
```
@@ -126,7 +158,7 @@ isort .
---
> 🔒 **Важно**:
> - Никогда не коммитьте `.env`, пароли или логины.
> - Файл `.gitignore` уже исключает все чувствительные артефакты.
> 🔒 **Важно**:
> - Никогда не коммитьте `.env`, пароли или логины.
> - Файл `.gitignore` уже исключает все чувствительные артефакты.
> - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine.

View File

@@ -4,25 +4,25 @@ build-backend = "setuptools.build_meta"
[project]
name = "redmine-reporter"
version = "0.1.1"
version = "1.4.1"
description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
license = { text = "Proprietary" }
license = { text = "MIT" }
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Operating System :: POSIX :: Linux",
"Environment :: Console",
]
requires-python = ">=3.8"
requires-python = ">=3.9"
dependencies = [
"python-redmine>=2.4.0",
"tabulate>=0.9.0",
"python-dotenv>=1.0.0",
"odfpy>=1.4.0",
]
[project.optional-dependencies]
@@ -41,9 +41,12 @@ redmine-reporter = "redmine_reporter.cli:main"
where = ["."]
include = ["redmine_reporter*"]
[tool.setuptools.package-data]
"redmine_reporter" = ["templates/template.odt"]
[tool.black]
line-length = 100
target-version = ['py38']
target-version = ['py39']
[tool.isort]
profile = "black"

View File

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

View File

@@ -1,10 +1,13 @@
import os
import sys
import argparse
from typing import List, Optional
from redminelib.resources import Issue
from .config import Config
from .client import fetch_issues_with_spent_time
from .formatter import format_compact, format_table
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]:
@@ -19,18 +22,26 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
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(
"--date",
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 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(
"--compact",
action="store_true",
help="Use compact plain-text output instead of table"
"--compact", action="store_true", help="Use compact plain-text output instead of table"
)
parser.add_argument(
"--output",
help="Path to output .odt file (e.g., report.odt). If omitted, prints to stdout.",
)
parser.add_argument(
"--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"
)
parser.add_argument(
"--no-time", action="store_true", help="Do not include spent time into table"
)
args = parser.parse_args(argv)
@@ -58,15 +69,49 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]")
try:
rows = build_grouped_report(issue_hours, fill_time=not args.no_time)
if args.output:
output_ext = os.path.splitext(args.output)[1].lower()
formatter = get_formatter_by_extension(
output_ext, author=Config.get_author(args.author), from_date=from_date, to_date=to_date
)
if not formatter:
print(f"❌ Неизвестный формат файла: {output_ext}", file=sys.stderr)
return 1
try:
formatter.save(rows, args.output)
print(f"✅ Report saved to {args.output}")
except ImportError as e:
if output_ext == ".odt":
print("❌ odfpy is not installed. Install with: pip install odfpy", file=sys.stderr)
else:
print(f"❌ Import error: {e}", file=sys.stderr)
return 1
except Exception as e:
fmt = "ODT" if output_ext == ".odt" else ("CSV" if output_ext == ".csv" else "Markdown")
print(f"{fmt} export error: {e}", file=sys.stderr)
return 1
else:
if args.compact:
output = format_compact(issue_hours)
formatter = get_console_formatter("compact")
else:
output = format_table(issue_hours)
print(output)
except Exception as e:
print(f"Formatting error: {e}", file=sys.stderr)
return 1
formatter = get_console_formatter("table")
if not formatter:
print("Неизвестный тип консольного форматтера.", file=sys.stderr)
return 1
try:
output = formatter.format(rows)
print(output)
except Exception as e:
print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1
return 0

View File

@@ -5,7 +5,9 @@ from .config import Config
from .utils import get_version
def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[Tuple[Issue, float]]]:
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,
along with total spent hours per issue.
@@ -16,21 +18,19 @@ def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[
Config.REDMINE_URL,
username=Config.REDMINE_USER,
password=Config.REDMINE_PASSWORD,
requests={'verify': '/etc/ssl/certs/ca-certificates.crt'}
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(
user_id=current_user.id,
from_date=from_date,
to_date=to_date
user_id=current_user.id, from_date=from_date, to_date=to_date
)
# Агрегируем часы по issue.id
spent_time: Dict[int, float] = {}
issue_ids = set()
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
issue_ids.add(iid)
spent_time[iid] = spent_time.get(iid, 0.0) + float(entry.hours)
@@ -39,12 +39,8 @@ def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[
return None
# Загружаем полные объекты задач
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'
)
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")
# Сопоставляем задачи с суммарным временем
result = []

View File

@@ -1,17 +1,26 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
REDMINE_URL = os.getenv("REDMINE_URL", "").rstrip("/")
REDMINE_URL = os.getenv("REDMINE_URL", "").strip().rstrip("/")
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_author(cls, cli_author: str = "") -> str:
"""Возвращает автора: из CLI если задан, иначе из .env, иначе — заглушку."""
if cli_author:
return cli_author
if cls.REDMINE_AUTHOR:
return cls.REDMINE_AUTHOR
return ""
@classmethod
def get_default_date_range(cls) -> str:
if cls.DEFAULT_FROM_DATE and cls.DEFAULT_TO_DATE:

View File

@@ -1,83 +0,0 @@
from typing import List, Tuple
from redminelib.resources import Issue
from .utils import get_version
STATUS_TRANSLATION = {
'Closed': 'Закрыто',
'Re-opened': 'В работе',
'New': 'В работе',
'Resolved': 'Решена',
'Pending': 'Ожидание',
'Feedback': 'В работе',
'In Progress': 'В работе',
'Rejected': 'Закрыто',
'Confirming': 'Ожидание',
}
def hours_to_human(hours: float) -> str:
if hours <= 0:
return ""
total_minutes = round(hours * 60)
h = total_minutes // 60
m = total_minutes % 60
parts = []
if h:
parts.append(f"{h}ч")
if m:
parts.append(f"{m}м")
return " ".join(parts) if parts else ""
def format_compact(issue_hours: List[Tuple[Issue, float]]) -> str:
lines = []
prev_project = None
prev_version = None
for issue, hours in issue_hours:
project = str(issue.project)
version = get_version(issue)
status = str(issue.status)
display_project = project if project != prev_project else ""
display_version = version if (project != prev_project or version != prev_version) else ""
lines.append(f"{display_project} | {display_version} | {issue.id}. {issue.subject} | {status} | {hours_to_human(hours)}")
prev_project = project
prev_version = version
return "\n".join(lines)
def format_table(issue_hours: List[Tuple[Issue, float]]) -> str:
from tabulate import tabulate
rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']]
prev_project = None
prev_version = None
for issue, hours in issue_hours:
project = str(issue.project)
version = get_version(issue)
status_en = str(issue.status)
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
display_project = project if project != prev_project else ""
display_version = version if (project != prev_project or version != prev_version) else ""
rows.append([
display_project,
display_version,
f"{issue.id}. {issue.subject}",
status_ru,
hours_to_human(hours)
])
prev_project = project
prev_version = version
return tabulate(rows, headers="firstrow", tablefmt="fancy_grid")

View File

@@ -0,0 +1,27 @@
from abc import ABC, abstractmethod
from typing import List
from ..types import ReportRow
class Formatter(ABC):
"""
Абстрактный базовый класс для всех форматтеров.
Определяет общий интерфейс для форматирования отчета.
"""
@abstractmethod
def format(self, rows: List[ReportRow]) -> str:
"""
Форматирует список строк отчета в нужный формат.
Возвращает строковое представление отчета.
"""
pass
@abstractmethod
def save(self, rows: List[ReportRow], output_path: str) -> None:
"""
Сохраняет отформатированный отчет в файл по указанному пути.
Для форматтеров, которые не поддерживают сохранение (например, консольные),
можно вызывать `format` и записывать результат вручную.
"""
pass

View File

@@ -0,0 +1,45 @@
from typing import List
from tabulate import tabulate
from .base import Formatter
from ..types import ReportRow
class TableFormatter(Formatter):
"""Форматтер для вывода красивой таблицы в консоль."""
def format(self, rows: List[ReportRow]) -> str:
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
for r in rows:
table_rows.append(
[
r["display_project"],
r["display_version"],
f"{r['issue_id']}. {r['subject']}",
r["status_ru"],
r["time_text"],
]
)
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
def save(self, rows: List[ReportRow], output_path: str) -> None:
# Консольные форматтеры не умеют сохранять в файл напрямую.
# Это делается в CLI.
raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.")
class CompactFormatter(Formatter):
"""Форматтер для компактного вывода в консоль."""
def format(self, rows: List[ReportRow]) -> str:
lines = []
for r in rows:
lines.append(
f"{r['display_project']} | {r['display_version']} | "
f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {r['time_text']}"
)
return "\n".join(lines)
def save(self, rows: List[ReportRow], output_path: str) -> None:
# Консольные форматтеры не умеют сохранять в файл напрямую.
# Это делается в CLI.
raise NotImplementedError("CompactFormatter не поддерживает сохранение в файл.")

View File

@@ -0,0 +1,34 @@
import csv
import io
from typing import List
from .base import Formatter
from ..types import ReportRow
class CSVFormatter(Formatter):
"""Форматтер для экспорта в CSV."""
def __init__(self, **kwargs):
super().__init__()
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"])
for r in rows:
writer.writerow(
[
r["project"],
r["version"],
r["issue_id"],
r["subject"],
r["status_ru"],
r["time_text"],
]
)
return output.getvalue()
def save(self, rows: List[ReportRow], output_path: str) -> None:
content = self.format(rows)
with open(output_path, "w", encoding="utf-8", newline="") as f:
f.write(content)

View File

@@ -0,0 +1,43 @@
from typing import Dict, Type, Optional
from .base import Formatter
from .console import TableFormatter, CompactFormatter
from .csv import CSVFormatter
from .markdown import MarkdownFormatter
from .odt import ODTFormatter
from .html import HTMLFormatter
# Словарь для сопоставления расширений файлов с классами форматтеров
FORMATTER_MAP: Dict[str, Type[Formatter]] = {
".odt": ODTFormatter,
".csv": CSVFormatter,
".md": MarkdownFormatter,
".html": HTMLFormatter,
}
# Словарь для сопоставления типа вывода (консоль) с классами форматтеров
CONSOLE_FORMATTER_MAP: Dict[str, Type[Formatter]] = {
"table": TableFormatter,
"compact": CompactFormatter,
}
def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]:
"""
Возвращает экземпляр форматтера по расширению файла.
Ключевые аргументы (**kwargs) передаются в конструктор форматтера.
"""
formatter_class = FORMATTER_MAP.get(extension.lower())
if formatter_class:
return formatter_class(**kwargs)
return None
def get_console_formatter(formatter_type: str) -> Optional[Formatter]:
"""
Возвращает экземпляр консольного форматтера по его типу.
"""
formatter_class = CONSOLE_FORMATTER_MAP.get(formatter_type.lower())
if formatter_class:
return formatter_class()
return None

View File

@@ -0,0 +1,79 @@
from typing import List, Dict, Any
from .base import Formatter
from ..types import ReportRow
class HTMLFormatter(Formatter):
"""Форматтер для экспорта отчёта в HTML."""
def __init__(self, **kwargs):
super().__init__()
def format(self, rows: List[ReportRow]) -> str:
# Сгруппируем данные
projects: Dict[str, Dict[str, List[ReportRow]]] = {}
for r in rows:
proj = r["project"]
ver = r["version"]
if proj not in projects:
projects[proj] = {}
if ver not in projects[proj]:
projects[proj][ver] = []
projects[proj][ver].append(r)
lines = [
'<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse; font-family: Arial, sans-serif;">',
" <thead>",
" <tr>",
" <th>Наименование Проекта</th>",
" <th>Номер версии*</th>",
" <th>Задача</th>",
" <th>Статус Готовность*</th>",
" <th>Затрачено за отчетный период</th>",
" </tr>",
" </thead>",
" <tbody>",
]
for project, versions in projects.items():
total_project_rows = sum(len(tasks) for tasks in versions.values())
first_version_in_project = True
for version, task_rows in versions.items():
row_span_version = len(task_rows)
first_row_in_version = True
for r in task_rows:
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>'
)
# Ячейка "Версия" - только в первой строке версии
if first_row_in_version:
lines.append(
f' <td rowspan="{row_span_version}" style="vertical-align: top;">{version}</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(" </tr>")
first_version_in_project = False
lines.append(" </tbody>")
lines.append("</table>")
return "\n".join(lines)
def save(self, rows: List[ReportRow], output_path: str) -> None:
content = self.format(rows)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)

View File

@@ -0,0 +1,28 @@
from typing import List
from .base import Formatter
from ..types import ReportRow
class MarkdownFormatter(Formatter):
"""Форматтер для экспорта в Markdown."""
def __init__(self, **kwargs):
super().__init__()
def format(self, rows: List[ReportRow]) -> str:
lines = [
"| Проект | Версия | Задача | Статус | Затрачено |",
"|--------|--------|--------|--------|-----------|",
]
for r in rows:
task_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']} |"
)
return "\n".join(lines)
def save(self, rows: List[ReportRow], output_path: str) -> None:
content = self.format(rows)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)

View File

@@ -0,0 +1,163 @@
import os
from importlib import resources
from typing import List
from odf.opendocument import load
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
class ODTFormatter(Formatter):
"""Форматтер для экспорта в ODT."""
def __init__(self, author: str = "", from_date: str = "", to_date: str = ""):
"""
Инициализирует форматтер с параметрами для шапки отчета.
"""
self.author = author
self.from_date = from_date
self.to_date = to_date
def format(self, rows: List[ReportRow]) -> "OpenDocument":
"""
Форматирует данные в объект OpenDocument.
"""
with (
resources.files("redmine_reporter")
.joinpath("templates", "template.odt")
.open("rb") as f
):
doc = load(f)
para_style_name = "Standard"
# Заголовок
month_name = get_month_name_from_range(self.from_date, self.to_date)
header_text = f"{self.author}. Отчет за месяц {month_name}."
doc.text.addElement(P(stylename=para_style_name, text=header_text))
doc.text.addElement(P(stylename=para_style_name, text=""))
# Стиль ячеек
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_style.addElement(cell_props)
doc.automaticstyles.addElement(cell_style)
# Таблица
table = Table(name="Report")
column_widths = ["1.56in", "1.63in", "3.93in", "1.56in", "1.43in"]
for width in column_widths:
col_style = Style(name=f"col_{width}", family="table-column")
col_props = TableColumnProperties(columnwidth=width)
col_style.addElement(col_props)
doc.automaticstyles.addElement(col_style)
table.addElement(TableColumn(stylename=col_style))
# Заголовки
header_row = TableRow()
for text in [
"Наименование Проекта",
"Номер версии*",
"Задача",
"Статус Готовность*",
"Затрачено за отчетный период",
]:
cell = TableCell(stylename=cell_style_name)
cell.addElement(P(stylename=para_style_name, text=text))
header_row.addElement(cell)
table.addElement(header_row)
projects = {}
for r in rows:
project = r["project"]
version = r["version"]
if project not in projects:
projects[project] = {}
if version not in projects[project]:
projects[project][version] = []
projects[project][version].append(r)
# Данные с двухуровневой группировкой и объединением ячеек
for project, versions in projects.items():
total_project_rows = sum(
len(rows_for_version) for rows_for_version in versions.values()
)
first_version_in_project = True
for version, rows_for_version in versions.items():
row_span_version = len(rows_for_version)
first_row_in_version = True
for r in rows_for_version:
row = TableRow()
# Ячейка "Проект" - только в первой строке всего проекта
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))
p = P(stylename=para_style_name, text=project) # Полное название проекта
cell_project.addElement(p)
row.addElement(cell_project)
# Ячейка "Версия" - только в первой строке каждой версии
if first_row_in_version:
cell_version = TableCell(stylename=cell_style_name)
cell_version.setAttribute("numberrowsspanned", str(row_span_version))
p = P(stylename=para_style_name, text=version)
cell_version.addElement(p)
row.addElement(cell_version)
first_row_in_version = False
else:
# Пропускаем - уже объединена
pass
# Остальные колонки
task_cell = TableCell(stylename=cell_style_name)
task_text = f"{r['issue_id']}. {r['subject']}"
p = P(stylename=para_style_name, text=task_text)
task_cell.addElement(p)
row.addElement(task_cell)
status_cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=r["status_ru"])
status_cell.addElement(p)
row.addElement(status_cell)
time_cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=r["time_text"])
time_cell.addElement(p)
row.addElement(time_cell)
table.addElement(row)
first_version_in_project = False
doc.text.addElement(table)
doc.text.addElement(P(stylename=para_style_name, text=""))
# Справка
for line in [
"«Наименование Проекта» - Имя собственное устройства или программного обеспечения.",
"«Номер версии» - Версия в проекте. Опциональное поле.",
"«Задача» - Номер по Redmine и формулировка.",
"«Статус» - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена.",
"«Готовность» Опциональное поле в процентах.",
"«Затрачено за отчетный период» - в днях или часах.",
]:
doc.text.addElement(P(stylename=para_style_name, text=line))
return doc
def save(self, rows: List[ReportRow], output_path: str) -> None:
"""
Сохраняет сформированный документ в файл.
"""
doc = self.format(rows)
doc.save(output_path)

View File

@@ -0,0 +1,65 @@
from typing import List, Tuple, cast
from redminelib.resources import Issue
from .types import ReportRow
from .utils import get_version, hours_to_human
STATUS_TRANSLATION = {
"New": "В работе",
"In Progress": "В работе",
"Feedback": "В работе",
"Re-opened": "В работе",
"Code Review": "Решена",
"Wait Release": "Закрыто",
"Pending": "Ожидание",
"Resolved": "Решена",
"Testing": "Решена",
"Confirming": "Ожидание",
"Closed": "Закрыто",
"Rejected": "Закрыто",
"Frozen": "Ожидание",
}
def build_grouped_report(
issue_hours: List[Tuple[Issue, float]],
fill_time: bool = True,
) -> List[ReportRow]:
"""
Преобразует список задач с затраченным временем в плоский список строк отчёта,
с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений).
"""
rows: List[ReportRow] = []
prev_project: str = ""
prev_version: str = ""
for issue, hours in issue_hours:
project = str(issue.project)
version = get_version(issue)
status_en = str(issue.status)
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
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 ""
rows.append(
cast(
ReportRow,
{
"project": project,
"version": version,
"display_project": display_project,
"display_version": display_version,
"issue_id": issue.id,
"subject": issue.subject,
"status_ru": status_ru,
"time_text": time_text,
},
)
)
prev_project = project
prev_version = version
return rows

Binary file not shown.

14
redmine_reporter/types.py Normal file
View File

@@ -0,0 +1,14 @@
from typing import TypedDict
class ReportRow(TypedDict):
"""Строка итогового отчёта."""
project: str
version: str
display_project: str
display_version: str
issue_id: int
subject: str
status_ru: str
time_text: str

View File

@@ -1,2 +1,52 @@
from datetime import datetime
def get_month_name_from_range(from_date: str, to_date: str) -> str:
"""Определяет название месяца по диапазону дат"""
try:
end = datetime.strptime(to_date, "%Y-%m-%d")
except ValueError:
return "Январь"
months = [
"",
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
]
return months[end.month]
def get_version(issue) -> str:
return str(getattr(issue, 'fixed_version', '<N/A>'))
"""Возвращает версию задачи или '<N/A>', если не задана."""
return str(getattr(issue, "fixed_version", "<N/A>"))
def hours_to_human(hours: float) -> str:
"""Преобразует часы в человекочитаемый формат: '2ч 30м'."""
if hours <= 0:
return ""
total_minutes = round(hours * 60)
h = total_minutes // 60
m = total_minutes % 60
parts = []
if h:
parts.append(f"{h}ч")
if m:
parts.append(f"{m}м")
return " ".join(parts) if parts else ""

View File

@@ -1,20 +1,22 @@
import pytest
from redmine_reporter.cli import parse_date_range
import sys
from io import StringIO
from unittest import mock
from redmine_reporter.cli import main
def test_parse_date_range_valid():
assert parse_date_range("2025-01-01--2025-12-31") == ("2025-01-01", "2025-12-31")
def test_parse_date_range_with_spaces():
assert parse_date_range("2025-01-01 -- 2025-12-31") == ("2025-01-01", "2025-12-31")
def test_parse_date_range_invalid_no_separator():
with pytest.raises(ValueError, match="must be in format"):
parse_date_range("2025-01-01")
def test_parse_date_range_invalid_parts():
with pytest.raises(ValueError, match="Invalid date range format"):
parse_date_range("2025-01-01--")
@mock.patch.dict(
"os.environ",
{"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_USER": "x", "REDMINE_PASSWORD": "y"},
)
@mock.patch("redmine_reporter.client.fetch_issues_with_spent_time")
def test_cli_smoke(mock_fetch):
mock_fetch.return_value = []
old_stdout = sys.stdout
sys.stdout = captured = StringIO()
try:
code = main(["--date", "2026-01-01--2026-01-31"])
assert code == 0
output = captured.getvalue()
assert "Total issues: 0" in output
finally:
sys.stdout = old_stdout

36
tests/test_client.py Normal file
View File

@@ -0,0 +1,36 @@
import pytest
from unittest import mock
from redmine_reporter.client import fetch_issues_with_spent_time
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_issues_with_spent_time(mock_redmine_class):
# Подготовка моков
mock_redmine = mock_redmine_class.return_value
mock_user = mock.MagicMock()
mock_user.id = 123
mock_redmine.user.get.return_value = mock_user
# Два time entry на одну задачу
mock_entry1 = mock.MagicMock()
mock_entry1.issue.id = 101
mock_entry1.hours = 2.0
mock_entry2 = mock.MagicMock()
mock_entry2.issue.id = 101
mock_entry2.hours = 1.5
mock_redmine.time_entry.filter.return_value = [mock_entry1, mock_entry2]
# Мок задачи
mock_issue = mock.MagicMock()
mock_issue.id = 101
mock_issue.project = "Проект X"
mock_issue.subject = "Тестовая задача"
mock_issue.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
assert len(result) == 1
issue, total_hours = result[0]
assert total_hours == 3.5

24
tests/test_config.py Normal file
View File

@@ -0,0 +1,24 @@
import os
import pytest
from unittest import mock
from redmine_reporter.config import Config
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_USER": "test", "REDMINE_PASSWORD": "secret"},
)
def test_config_valid():
Config.validate() # не должно быть исключения
@mock.patch.dict(os.environ, {}, clear=True)
def test_config_missing():
with pytest.raises(ValueError, match="REDMINE_URL"):
Config.validate()
@mock.patch.dict(os.environ, {"REDMINE_AUTHOR": "Иванов И.И."})
def test_get_author():
assert Config.get_author("") == "Иванов И.И."
assert Config.get_author("Петров П.П.") == "Петров П.П."

178
tests/test_formatters.py Normal file
View File

@@ -0,0 +1,178 @@
import pytest
import os
from typing import List
from redmine_reporter.types import ReportRow
from redmine_reporter.formatters.console import TableFormatter, CompactFormatter
from redmine_reporter.formatters.csv import CSVFormatter
from redmine_reporter.formatters.markdown import MarkdownFormatter
from redmine_reporter.formatters.odt import ODTFormatter
from odf.opendocument import OpenDocument
def make_fake_report_rows() -> List[ReportRow]:
"""
Генерирует фейковый отчёт с полным покрытием логики группировки:
- Проект A: версия v1.0 (2 задачи), версия v2.0 (1 задача)
- Проект B: версия <N/A> (1 задача)
- Проект C: версия v1.0 (1 задача), версия v1.1 (2 задачи)
"""
return [
# Проект A, v1.0
{
"project": "Проект A",
"version": "v1.0",
"display_project": "Проект A",
"display_version": "v1.0",
"issue_id": 101,
"subject": "Реализовать фичу X",
"status_ru": "В работе",
"time_text": "4ч 30м",
},
{
"project": "Проект A",
"version": "v1.0",
"display_project": "",
"display_version": "",
"issue_id": 102,
"subject": "Исправить баг Y",
"status_ru": "Решена",
"time_text": "",
},
# Проект A, v2.0
{
"project": "Проект A",
"version": "v2.0",
"display_project": "",
"display_version": "v2.0",
"issue_id": 103,
"subject": "Документация Z",
"status_ru": "Ожидание",
"time_text": "",
},
# Проект B, без версии
{
"project": "Проект B",
"version": "<N/A>",
"display_project": "Проект B",
"display_version": "<N/A>",
"issue_id": 201,
"subject": "Обновить README",
"status_ru": "Закрыто",
"time_text": "",
},
# Проект C, v1.0
{
"project": "Проект C",
"version": "v1.0",
"display_project": "Проект C",
"display_version": "v1.0",
"issue_id": 301,
"subject": "Настроить CI",
"status_ru": "В работе",
"time_text": "3ч 15м",
},
# Проект C, v1.1
{
"project": "Проект C",
"version": "v1.1",
"display_project": "",
"display_version": "v1.1",
"issue_id": 302,
"subject": "Добавить тесты",
"status_ru": "В работе",
"time_text": "",
},
{
"project": "Проект C",
"version": "v1.1",
"display_project": "",
"display_version": "",
"issue_id": 303,
"subject": "Рефакторинг",
"status_ru": "Решена",
"time_text": "6ч 45м",
},
]
@pytest.fixture
def fake_rows():
return make_fake_report_rows()
def _get_formatter_output_text(result):
"""Преобразует результат форматтера в строку для проверки содержимого."""
if isinstance(result, OpenDocument):
return ""
elif isinstance(result, str):
return result
else:
raise TypeError(f"Unexpected formatter output type: {type(result)}")
FORMATTER_FACTORIES = [
("table", lambda: TableFormatter()),
("compact", lambda: CompactFormatter()),
("csv", lambda: CSVFormatter()),
("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)
def test_formatter_does_not_crash(fake_rows, name, formatter_factory):
"""Проверяем, что форматтер не падает на валидных данных."""
formatter = formatter_factory()
result = formatter.format(fake_rows)
if name == "odt":
assert isinstance(result, OpenDocument)
else:
assert isinstance(result, str)
assert len(result.strip()) > 0
@pytest.mark.parametrize("name, formatter_factory", FORMATTER_FACTORIES)
def test_formatter_contains_key_content(fake_rows, name, formatter_factory):
"""Проверяем, что вывод содержит ключевые элементы."""
formatter = formatter_factory()
result = formatter.format(fake_rows)
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":
# В CSV ID и subject — отдельные колонки
assert "101" in output_text
assert "Реализовать фичу X" in output_text
else:
# В остальных — вместе
assert "101. Реализовать фичу X" in output_text
def test_odt_save_creates_valid_file(fake_rows, tmp_path):
"""Проверяем, что ODT можно сохранить и он открывается как ZIP."""
output_file = tmp_path / "report.odt"
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
formatter.save(fake_rows, str(output_file))
assert output_file.exists()
with open(output_file, "rb") as f:
assert f.read(2) == b"PK" # сигнатура ZIP

View File

@@ -0,0 +1,45 @@
import pytest
from redmine_reporter.report_builder import build_grouped_report, STATUS_TRANSLATION
from redmine_reporter.utils import get_version
class MockIssue:
def __init__(self, project, subject, status, fixed_version=None, issue_id=999):
self.id = issue_id
self.project = project
self.subject = subject
self.status = status
if fixed_version is not None:
self.fixed_version = fixed_version
def test_status_translation():
assert STATUS_TRANSLATION["Closed"] == "Закрыто"
assert STATUS_TRANSLATION["New"] == "В работе"
assert STATUS_TRANSLATION["Resolved"] == "Решена"
def test_build_grouped_report():
issues = [
(MockIssue("Камеры", "Фича A", "New", "v2.5.0", 101), 2.0),
(MockIssue("Камеры", "Баг B", "Resolved", "v2.5.0", 102), 1.5),
(MockIssue("ПО", "Доки", "Pending", None, 201), 4.0),
]
rows = build_grouped_report(issues)
assert len(rows) == 3
# Первая строка — полное название проекта и версии
assert rows[0]["display_project"] == "Камеры"
assert rows[0]["display_version"] == "v2.5.0"
# Вторая — пустые display_* из-за совпадения
assert rows[1]["display_project"] == ""
assert rows[1]["display_version"] == ""
# Третья — новый проект
assert rows[2]["display_project"] == "ПО"
assert rows[2]["display_version"] == "<N/A>"
# Проверка перевода и времени
assert rows[0]["status_ru"] == "В работе"
assert rows[0]["time_text"] == ""
assert rows[1]["time_text"] == "1ч 30м"

28
tests/test_utils.py Normal file
View File

@@ -0,0 +1,28 @@
import pytest
from redmine_reporter.utils import hours_to_human, get_month_name_from_range, get_version
def test_hours_to_human():
assert hours_to_human(0) == ""
assert hours_to_human(1.0) == ""
assert hours_to_human(2.5) == "2ч 30м"
assert hours_to_human(0.75) == "45м"
assert hours_to_human(3.1666) == "3ч 10м" # ≈ 3ч 10м
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("2025-12-01", "2026-02-15") == "Февраль" # берётся to_date
assert get_month_name_from_range("invalid", "also_invalid") == "Январь" # fallback
def test_get_version():
class MockIssue:
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(issue_without) == "<N/A>"