feat(formatter): unify data pipeline with ReportRow and report_builder

This commit is contained in:
Кокос Артем Николаевич
2026-01-24 16:09:25 +07:00
parent 245ea0a3fa
commit e344715f61
8 changed files with 194 additions and 234 deletions

View File

@@ -5,6 +5,7 @@ from redminelib.resources import Issue
from .config import Config from .config import Config
from .client import fetch_issues_with_spent_time from .client import fetch_issues_with_spent_time
from .report_builder import build_grouped_report
from .formatter import format_compact, format_table from .formatter import format_compact, format_table
from .formatter_odt import format_odt from .formatter_odt import format_odt
from .formatter_csv import format_csv from .formatter_csv import format_csv
@@ -76,6 +77,8 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]") 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 args.output:
if not (args.output.endswith(".odt") or args.output.endswith(".csv") or args.output.endswith(".md")): if not (args.output.endswith(".odt") or args.output.endswith(".csv") or args.output.endswith(".md")):
print("❌ Output file must end with .odt, .csv or .md", file=sys.stderr) print("❌ Output file must end with .odt, .csv or .md", file=sys.stderr)
@@ -84,21 +87,20 @@ def main(argv: Optional[List[str]] = None) -> int:
try: try:
if args.output.endswith(".odt"): if args.output.endswith(".odt"):
doc = format_odt( doc = format_odt(
issue_hours, rows,
author=Config.get_author(args.author), author=Config.get_author(args.author),
from_date=from_date, from_date=from_date,
to_date=to_date, to_date=to_date,
fill_time=not args.no_time
) )
doc.save(args.output) doc.save(args.output)
elif args.output.endswith(".csv"): elif args.output.endswith(".csv"):
csv_content = format_csv(issue_hours, fill_time=not args.no_time) content = format_csv(rows)
with open(args.output, "w", encoding="utf-8", newline="") as f: with open(args.output, "w", encoding="utf-8", newline="") as f:
f.write(csv_content) f.write(content)
elif args.output.endswith(".md"): elif args.output.endswith(".md"):
md_content = format_md(issue_hours, fill_time=not args.no_time) content = format_md(rows)
with open(args.output, "w", encoding="utf-8") as f: with open(args.output, "w", encoding="utf-8") as f:
f.write(md_content) f.write(content)
print(f"✅ Report saved to {args.output}") print(f"✅ Report saved to {args.output}")
except ImportError as e: except ImportError as e:
@@ -114,9 +116,9 @@ def main(argv: Optional[List[str]] = None) -> int:
else: else:
try: try:
if args.compact: if args.compact:
output = format_compact(issue_hours, fill_time=not args.no_time) output = format_compact(rows)
else: else:
output = format_table(issue_hours, fill_time=not args.no_time) output = format_table(rows)
print(output) print(output)
except Exception as e: except Exception as e:
print(f"❌ Formatting error: {e}", file=sys.stderr) print(f"❌ Formatting error: {e}", file=sys.stderr)

View File

@@ -1,85 +1,30 @@
from typing import List, Tuple from typing import List
from redminelib.resources import Issue from tabulate import tabulate
from .utils import get_version from .types import ReportRow
STATUS_TRANSLATION = { def format_compact(rows: List[ReportRow]) -> str:
'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]], fill_time: bool = True) -> str:
lines = [] lines = []
prev_project = None
prev_version = None
for issue, hours in issue_hours: for r in rows:
project = str(issue.project) lines.append(
version = get_version(issue) f"{r['display_project']} | {r['display_version']} | "
status = str(issue.status) f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {r['time_text']}"
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 ""
lines.append(f"{display_project} | {display_version} | {issue.id}. {issue.subject} | {status} | {time_text}")
prev_project = project
prev_version = version
return "\n".join(lines) return "\n".join(lines)
def format_table(issue_hours: List[Tuple[Issue, float]], fill_time: bool = True) -> str: def format_table(rows: List[ReportRow]) -> str:
from tabulate import tabulate table_rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']]
rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']] for r in rows:
prev_project = None table_rows.append([
prev_version = None r['display_project'],
r['display_version'],
for issue, hours in issue_hours: f"{r['issue_id']}. {r['subject']}",
project = str(issue.project) r['status_ru'],
version = get_version(issue) r['time_text']
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([
display_project,
display_version,
f"{issue.id}. {issue.subject}",
status_ru,
time_text
]) ])
prev_project = project return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
prev_version = version
return tabulate(rows, headers="firstrow", tablefmt="fancy_grid")

View File

@@ -1,40 +1,22 @@
import csv import csv
import io import io
from typing import List, Tuple from typing import List
from redminelib.resources import Issue from .types import ReportRow
from .formatter import get_version, hours_to_human, STATUS_TRANSLATION
def format_csv( def format_csv(rows: List[ReportRow]) -> str:
issue_hours: List[Tuple[Issue, float]],
fill_time: bool = True,
dialect: str = "excel"
) -> str:
"""
Formats the list of issues with spent time into CSV.
Returns a string containing the CSV content.
"""
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output, dialect=dialect) writer = csv.writer(output, dialect="excel")
# Header
writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]) writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"])
for issue, hours in issue_hours: for r in rows:
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 ""
writer.writerow([ writer.writerow([
project, r["project"],
version, r["version"],
issue.id, r["issue_id"],
issue.subject, r["subject"],
status_ru, r["status_ru"],
time_text r["time_text"]
]) ])
return output.getvalue() return output.getvalue()

View File

@@ -1,35 +1,19 @@
from typing import List, Tuple from typing import List
from redminelib.resources import Issue from .types import ReportRow
from .formatter import get_version, hours_to_human, STATUS_TRANSLATION
def format_md(issue_hours: List[Tuple[Issue, float]], fill_time: bool = True) -> str: def format_md(rows: List[ReportRow]) -> str:
""" lines = [
Formats the list of issues with spent time into a Markdown table. "| Проект | Версия | Задача | Статус | Затрачено |",
Returns a string containing the Markdown content. "|--------|--------|--------|--------|-----------|"
""" ]
lines = [] for r in rows:
lines.append("| Проект | Версия | Задача | Статус | Затрачено |") task_cell = f"{r['issue_id']}. {r['subject']}"
lines.append("|--------|--------|--------|--------|-----------|")
prev_project = None lines.append(
prev_version = None f"| {r['display_project']} | {r['display_version']} "
f"| {task_cell} | {r['status_ru']} | {r['time_text']} |"
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 ""
task_cell = f"{issue.id}. {issue.subject}"
lines.append(f"| {display_project} | {display_version} | {task_cell} | {status_ru} | {time_text} |")
prev_project = project
prev_version = version
return "\n".join(lines) return "\n".join(lines)

View File

@@ -1,21 +1,18 @@
import os import os
from typing import List, Tuple from typing import List
from redminelib.resources import Issue
from odf.opendocument import load from odf.opendocument import load
from odf.text import P from odf.text import P
from odf.table import Table, TableColumn, TableRow, TableCell from odf.table import Table, TableColumn, TableRow, TableCell
from odf.style import Style, TableColumnProperties, TableCellProperties from odf.style import Style, TableColumnProperties, TableCellProperties
from .types import ReportRow
from .formatter import get_version, hours_to_human, STATUS_TRANSLATION
from .utils import get_month_name_from_range from .utils import get_month_name_from_range
def format_odt( def format_odt(
issue_hours: List[Tuple[Issue, float]], rows: List[ReportRow],
author: str = "", author: str = "",
from_date: str = "", from_date: str = "",
to_date: str = "", to_date: str = "",
fill_time: bool = True
) -> "OpenDocument": ) -> "OpenDocument":
template_path = "template.odt" template_path = "template.odt"
if not os.path.exists(template_path): if not os.path.exists(template_path):
@@ -27,85 +24,61 @@ def format_odt(
# Заголовок # Заголовок
month_name = get_month_name_from_range(from_date, to_date) month_name = get_month_name_from_range(from_date, to_date)
header_text = f"{author}. Отчет за месяц {month_name}." header_text = f"{author}. Отчет за месяц {month_name}."
header_paragraph = P(stylename=para_style_name, text=header_text) doc.text.addElement(P(stylename=para_style_name, text=header_text))
doc.text.addElement(header_paragraph) doc.text.addElement(P(stylename=para_style_name, text=""))
# Добавляем пустую строку (новый параграф без текста) # Стиль ячеек
empty_paragraph = P(stylename=para_style_name, text="")
doc.text.addElement(empty_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))
# Создаем стиль для ячеек таблицы
cell_style_name = "TableCellStyle" cell_style_name = "TableCellStyle"
cell_style = Style(name=cell_style_name, family="table-cell") cell_style = Style(name=cell_style_name, family="table-cell")
cell_props = TableCellProperties(padding="0.04in", border="0.05pt solid #000000")
# Устанавливаем отступы (Padding)
cell_props = TableCellProperties(
padding="0.04in",
border="0.05pt solid #000000"
)
cell_style.addElement(cell_props) cell_style.addElement(cell_props)
doc.automaticstyles.addElement(cell_style) doc.automaticstyles.addElement(cell_style)
# Создаем стиль для всей таблицы (опционально, но может понадобиться) # Таблица
table_style_name = "ReportTableStyle" table = Table(name="Report")
table_style = Style(name=table_style_name, family="table")
# Создаем таблицу и применяем стиль
table = Table(name="Report", stylename=table_style_name)
# Добавляем стили для каждой колонки (ширины)
column_widths = ["1.56in", "1.63in", "3.93in", "1.56in", "1.43in"] column_widths = ["1.56in", "1.63in", "3.93in", "1.56in", "1.43in"]
for width in column_widths:
for i, width in enumerate(column_widths): col_style = Style(name=f"col_{width}", family="table-column")
col_style_name = f"col{i+1}" col_props = TableColumnProperties(columnwidth=width)
col_style = Style(name=col_style_name, family="table-column")
col_props = TableColumnProperties(columnwidth=width, breakbefore="auto")
col_style.addElement(col_props) col_style.addElement(col_props)
doc.automaticstyles.addElement(col_style) doc.automaticstyles.addElement(col_style)
table.addElement(TableColumn(stylename=col_style)) table.addElement(TableColumn(stylename=col_style))
# Заголовки # Заголовки
header_row = TableRow() header_row = TableRow()
headers = ["Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период"] for text in ["Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период"]:
for text in headers:
cell = TableCell(stylename=cell_style_name) cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=text) cell.addElement(P(stylename=para_style_name, text=text))
cell.addElement(p)
header_row.addElement(cell) header_row.addElement(cell)
table.addElement(header_row) 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(): for project, versions in projects.items():
total_project_rows = sum(len(rows) for rows in versions.values()) total_project_rows = sum(len(rows_for_version) for rows_for_version in versions.values())
first_version_in_project = True first_version_in_project = True
for version, rows in versions.items(): for version, rows_for_version in versions.items():
row_span_version = len(rows) row_span_version = len(rows_for_version)
first_row_in_version = True first_row_in_version = True
for issue, hours, status_ru in rows: for r in rows_for_version:
row = TableRow() row = TableRow()
# Ячейка "Проект" - только в первой строке всего проекта # Ячейка "Проект" - только в первой строке всего проекта
if first_version_in_project and first_row_in_version: if first_version_in_project and first_row_in_version:
cell_project = TableCell(stylename=cell_style_name) cell_project = TableCell(stylename=cell_style_name)
cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) cell_project.setAttribute("numberrowsspanned", str(total_project_rows))
p = P(stylename=para_style_name, text=project) p = P(stylename=para_style_name, text=project) # Полное название проекта
cell_project.addElement(p) cell_project.addElement(p)
row.addElement(cell_project) row.addElement(cell_project)
@@ -123,21 +96,18 @@ def format_odt(
# Остальные колонки # Остальные колонки
task_cell = TableCell(stylename=cell_style_name) task_cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=f"{issue.id}. {issue.subject}") task_text = f"{r['issue_id']}. {r['subject']}"
p = P(stylename=para_style_name, text=task_text)
task_cell.addElement(p) task_cell.addElement(p)
row.addElement(task_cell) row.addElement(task_cell)
status_cell = TableCell(stylename=cell_style_name) status_cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=status_ru) p = P(stylename=para_style_name, text=r["status_ru"])
status_cell.addElement(p) status_cell.addElement(p)
row.addElement(status_cell) row.addElement(status_cell)
time_cell = TableCell(stylename=cell_style_name) time_cell = TableCell(stylename=cell_style_name)
if fill_time: p = P(stylename=para_style_name, text=r["time_text"])
time_text = hours_to_human(hours)
else:
time_text = ""
p = P(stylename=para_style_name, text=time_text)
time_cell.addElement(p) time_cell.addElement(p)
row.addElement(time_cell) row.addElement(time_cell)
@@ -145,34 +115,17 @@ def format_odt(
first_version_in_project = False first_version_in_project = False
doc.text.addElement(table) doc.text.addElement(table)
# Добавляем пустую строку (новый параграф без текста)
doc.text.addElement(P(stylename=para_style_name, text="")) doc.text.addElement(P(stylename=para_style_name, text=""))
# Справка # Справка
doc.text.addElement(P( for line in [
stylename=para_style_name, "«Наименование Проекта» - Имя собственное устройства или программного обеспечения.",
text="“Наименование Проекта” - Имя собственное устройства или программного обеспечения." "«Номер версии» - Версия в проекте. Опциональное поле.",
)) "«Задача» - Номер по Redmine и формулировка.",
doc.text.addElement(P( "«Статус» - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена.",
stylename=para_style_name, "«Готовность» Опциональное поле в процентах.",
text="“Номер версии” - Версия в проекте. Опциональное поле." "«Затрачено за отчетный период» - в днях или часах."
)) ]:
doc.text.addElement(P( doc.text.addElement(P(stylename=para_style_name, text=line))
stylename=para_style_name,
text="“Задача” - Номер по Redmine и формулировка."
))
doc.text.addElement(P(
stylename=para_style_name,
text="“Статус” - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена."
))
doc.text.addElement(P(
stylename=para_style_name,
text="“Готовность” Опциональное поле в процентах."
))
doc.text.addElement(P(
stylename=para_style_name,
text="“Затрачено за отчетный период” - в днях или часах."
))
return doc return doc

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