20 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
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
26 changed files with 887 additions and 313 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

View File

@@ -16,7 +16,7 @@
- Перевод статусов на русский язык
- Простой CLI с понятными аргументами
- Поддержка настройки диапазона дат по умолчанию через `.env`
- Экспорт в ODT с автоматическим заголовком (автор + месяц)
- Экспорт в ODT, CSV и Markdown
---

View File

@@ -4,21 +4,20 @@ build-backend = "setuptools.build_meta"
[project]
name = "redmine-reporter"
version = "1.1.0"
version = "1.4.0"
description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
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",
@@ -42,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__ = "1.1.0"
__version__ = "1.4.0"

View File

@@ -1,3 +1,4 @@
import os
import sys
import argparse
from typing import List, Optional
@@ -5,8 +6,8 @@ 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 .formatter_odt import format_odt
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]:
@@ -43,6 +44,11 @@ def main(argv: Optional[List[str]] = None) -> int:
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)
try:
@@ -69,27 +75,47 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]")
rows = build_grouped_report(issue_hours, fill_time=not args.no_time)
if args.output:
if not args.output.endswith(".odt"):
print("❌ Output file must end with .odt", file=sys.stderr)
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:
author = Config.get_author(args.author)
doc = format_odt(issue_hours, author=author, from_date=from_date, to_date=to_date)
doc.save(args.output)
formatter.save(rows, args.output)
print(f"✅ Report saved to {args.output}")
except ImportError:
print("❌ odfpy is not installed. Install with: pip install odfpy", file=sys.stderr)
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:
print(f"ODT export error: {e}", file=sys.stderr)
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:
formatter = get_console_formatter("compact")
else:
formatter = get_console_formatter("table")
if not formatter:
print("❌ Неизвестный тип консольного форматтера.", file=sys.stderr)
return 1
try:
if args.compact:
output = format_compact(issue_hours)
else:
output = format_table(issue_hours)
output = formatter.format(rows)
print(output)
except Exception as e:
print(f"❌ Formatting error: {e}", file=sys.stderr)

View File

@@ -6,7 +6,7 @@ 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")

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

@@ -1,113 +0,0 @@
import os
from typing import List, Tuple
from redminelib.resources import Issue
from odf.opendocument import load
from odf.text import P
from odf.table import Table, TableColumn, TableRow, TableCell
from .formatter import get_version, hours_to_human, STATUS_TRANSLATION
from .utils import get_month_name_from_range
def format_odt(
issue_hours: List[Tuple[Issue, float]],
author: str = "",
from_date: str = "",
to_date: str = ""
) -> "OpenDocument":
template_path = "template.odt"
if not os.path.exists(template_path):
raise FileNotFoundError("Шаблон template.odt не найден...")
doc = load(template_path)
para_style_name = "Standard"
# Заголовок
month_name = get_month_name_from_range(from_date, to_date)
header_text = f"{author}. Отчет за месяц {month_name}."
header_paragraph = P(stylename=para_style_name, text=header_text)
doc.text.addElement(header_paragraph)
# Группировка: project - version - [(issue, hours, status_ru)]
projects = {}
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)
if project not in projects:
projects[project] = {}
if version not in projects[project]:
projects[project][version] = []
projects[project][version].append((issue, hours, status_ru))
# Создаём таблицу
table = Table(name="Report")
for _ in range(5):
table.addElement(TableColumn())
# Заголовки
header_row = TableRow()
headers = ["Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период"]
for text in headers:
cell = TableCell()
p = P(stylename=para_style_name, text=text)
cell.addElement(p)
header_row.addElement(cell)
table.addElement(header_row)
# Данные с двухуровневой группировкой и объединением ячеек
for project, versions in projects.items():
total_project_rows = sum(len(rows) for rows in versions.values())
first_version_in_project = True
for version, rows in versions.items():
row_span_version = len(rows)
first_row_in_version = True
for issue, hours, status_ru in rows:
row = TableRow()
# Ячейка "Проект" - только в первой строке всего проекта
if first_version_in_project and first_row_in_version:
cell_project = TableCell()
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()
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()
p = P(stylename=para_style_name, text=f"{issue.id}. {issue.subject}")
task_cell.addElement(p)
row.addElement(task_cell)
status_cell = TableCell()
p = P(stylename=para_style_name, text=status_ru)
status_cell.addElement(p)
row.addElement(status_cell)
time_cell = TableCell()
p = P(stylename=para_style_name, text=hours_to_human(hours))
time_cell.addElement(p)
row.addElement(time_cell)
table.addElement(row)
first_version_in_project = False
doc.text.addElement(table)
return doc

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,42 @@
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,32 @@
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,44 @@
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,151 @@
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,62 @@
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 = {
'Closed': 'Закрыто',
'Re-opened': 'В работе',
'New': 'В работе',
'Resolved': 'Решена',
'Pending': 'Ожидание',
'Feedback': 'В работе',
'In Progress': 'В работе',
'Rejected': 'Закрыто',
'Confirming': 'Ожидание',
}
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

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

@@ -2,22 +2,40 @@ from datetime import datetime
def get_month_name_from_range(from_date: str, to_date: str) -> str:
"""Определяет название месяца по диапазону дат.
- Если from == to - возвращает месяц этой даты.
- Если диапазон охватывает несколько месяцев - возвращает месяц из to_date.
"""
"""Определяет название месяца по диапазону дат"""
try:
end = datetime.strptime(to_date, "%Y-%m-%d")
except ValueError:
return "Январь" # fallback, хотя лучше бы не срабатывало
return "Январь"
months = [
"", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"
]
return months[end.month]
def get_version(issue) -> str:
"""Возвращает версию задачи или '<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,23 @@
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

25
tests/test_config.py Normal file
View File

@@ -0,0 +1,25 @@
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("Петров П.П.") == "Петров П.П."

View File

@@ -1,73 +0,0 @@
import tempfile
from types import SimpleNamespace
from redmine_reporter.formatter_odt import format_odt
def make_mock_issue(id_, project, subject, status, fixed_version=None):
"""Создаёт лёгкий mock-объект, имитирующий Issue из redminelib."""
issue = SimpleNamespace()
issue.id = id_
issue.project = project
issue.subject = subject
issue.status = status
if fixed_version is not None:
issue.fixed_version = fixed_version
return issue
def test_format_odt_basic():
issues = [
(make_mock_issue(101, "Камеры", "Поддержка нового датчика", "In Progress", "v2.5.0"), 2.5),
(make_mock_issue(102, "Камеры", "Исправить утечку памяти", "Resolved", "v2.5.0"), 4.0),
(make_mock_issue(103, "ПО", "Обновить документацию", "Pending", None), 12.0),
]
doc = format_odt(issues)
# Сохраняем и проверяем содержимое
with tempfile.NamedTemporaryFile(suffix=".odt") as tmp:
doc.save(tmp.name)
# Проверяем, что файл - это ZIP (ODT основан на ZIP)
with open(tmp.name, "rb") as f:
assert f.read(2) == b"PK"
# Извлекаем content.xml
import zipfile
with zipfile.ZipFile(tmp.name) as zf:
content_xml = zf.read("content.xml").decode("utf-8")
# Проверяем заголовки
assert "Проект" in content_xml
assert "Версия" in content_xml
assert "Задача" in content_xml
assert "Статус" in content_xml
assert "Затрачено" in content_xml
# Проверяем данные задач
assert "101. Поддержка нового датчика" in content_xml
assert "102. Исправить утечку памяти" in content_xml
assert "103. Обновить документацию" in content_xml
# Проверяем проекты и версии
assert "Камеры" in content_xml
assert "ПО" in content_xml
assert "v2.5.0" in content_xml
assert "&lt;N/A&gt;" in content_xml or "<N/A>" in content_xml # зависит от экранирования
# Проверяем перевод статусов
assert "В работе" in content_xml # In Progress
assert "Решена" in content_xml # Resolved
assert "Ожидание" in content_xml # Pending
# Проверяем формат времени
assert "2ч 30м" in content_xml
assert "" in content_xml
assert "12ч" in content_xml
# Проверяем группировку: "Камеры" должен встречаться только один раз явно
# (вторая строка — пустая ячейка)
cam_occurrences = content_xml.count(">Камеры<")
assert cam_occurrences == 1

175
tests/test_formatters.py Normal file
View File

@@ -0,0 +1,175 @@
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м"

27
tests/test_utils.py Normal file
View File

@@ -0,0 +1,27 @@
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>"