feat(xlsx): make Excel export production-grade

- Add merge cells for project/version groups.
- Add numeric Hours column and human-readable Spent Time.
- Add version/project/grand totals.
- Apply auto-width, freeze panes, auto-filter and styling.
- Respect --no-time: keep columns empty, skip totals.
- Pass no_time flag through formatter factory to all file formatters.
- Add tests for XLSX features and --no-time behavior.

Closes #38
This commit is contained in:
Кокос Артем Николаевич
2026-06-29 14:41:03 +07:00
parent 86f083aa79
commit 222d31730e
8 changed files with 300 additions and 33 deletions

6
.gitignore vendored
View File

@@ -88,7 +88,7 @@ secrets.json
# Just in case # Just in case
.~* .~*
report.odt # Local report outputs
report.csv report.*
report.md rep.*
*.html *.html

View File

@@ -1 +1 @@
__version__ = "1.6.0" __version__ = "1.6.1"

View File

@@ -145,6 +145,7 @@ def main(argv: Optional[List[str]] = None) -> int:
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,
no_time=args.no_time,
) )
if not formatter: if not formatter:

View File

@@ -15,24 +15,28 @@ class CSVFormatter(Formatter):
корректного отображения кириллицы в Microsoft Excel (#26). корректного отображения кириллицы в Microsoft Excel (#26).
""" """
def __init__(self, **_kwargs): def __init__(self, no_time: bool = False, **_kwargs):
super().__init__() super().__init__()
self.no_time = no_time
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output, dialect="excel") writer = csv.writer(output, dialect="excel")
writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]) headers = ["Project", "Version", "Issue ID", "Subject", "Status"]
if not self.no_time:
headers.append("Spent Time")
writer.writerow(headers)
for r in rows: for r in rows:
writer.writerow( data = [
[
r["project"], r["project"],
r["version"], r["version"],
r["issue_id"], r["issue_id"],
r["subject"], r["subject"],
r["status_ru"], r["status_ru"],
r["time_text"],
] ]
) if not self.no_time:
data.append(r["time_text"])
writer.writerow(data)
return output.getvalue() return output.getvalue()
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: List[ReportRow], output_path: str) -> None:

View File

@@ -15,7 +15,7 @@ from .base import Formatter
class ODTFormatter(Formatter): class ODTFormatter(Formatter):
"""Форматтер для экспорта в ODT.""" """Форматтер для экспорта в ODT."""
def __init__(self, author: str = "", from_date: str = "", to_date: str = ""): def __init__(self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs):
""" """
Инициализирует форматтер с параметрами для шапки отчета. Инициализирует форматтер с параметрами для шапки отчета.
""" """

View File

@@ -1,42 +1,214 @@
from typing import List from typing import Dict, List, Tuple
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.worksheet import Worksheet
from ..report_builder import group_rows_by_project_and_version
from ..types import ReportRow from ..types import ReportRow
from ..utils import hours_to_human
from .base import Formatter from .base import Formatter
class XLSXFormatter(Formatter): class XLSXFormatter(Formatter):
"""Форматтер для экспорта отчёта в Excel (.xlsx).""" """Форматтер для экспорта отчёта в Excel (.xlsx).
def __init__(self, **_kwargs): Использует группировку по проекту и версии: объединяет ячейки,
добавляет итоги по группам, закрепляет заголовок, включает автофильтр
и числовой столбец с часами для удобного суммирования.
"""
_HEADER_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
_TOTAL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
_BORDER = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
def __init__(self, no_time: bool = False, **_kwargs):
super().__init__() super().__init__()
self.no_time = no_time
def format(self, rows: List[ReportRow]) -> Workbook: def format(self, rows: List[ReportRow]) -> Workbook:
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
if ws is None:
ws = wb.create_sheet("Report")
else:
ws.title = "Report" ws.title = "Report"
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"] headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Hours", "Spent Time"]
ws.append(headers) ws.append(headers)
for cell in ws[1]: self._style_header_row(ws, headers)
cell.font = Font(bold=True)
for r in rows: grouped = group_rows_by_project_and_version(rows)
current_row = 2
project_ranges: List[Tuple[int, int]] = []
version_ranges: List[Tuple[int, int]] = []
project_totals: Dict[str, float] = {}
for project, versions in grouped.items():
project_start_row = current_row
for version, task_rows in versions.items():
version_start_row = current_row
for r in task_rows:
hours = "" if self.no_time else r.get("hours", 0.0)
time_text = "" if self.no_time else r["time_text"]
ws.append( ws.append(
[ [
r["project"], project,
r["version"], version,
r["issue_id"], r["issue_id"],
r["subject"], r["subject"],
r["status_ru"], r["status_ru"],
r["time_text"], hours,
time_text,
] ]
) )
self._style_data_row(ws, current_row)
current_row += 1
if not self.no_time:
version_hours = sum(r.get("hours", 0.0) for r in task_rows)
ws.append(
[
"",
f"Total {version}",
"",
"",
"",
version_hours,
hours_to_human(version_hours),
]
)
self._style_total_row(ws, current_row, bold=False)
ws.merge_cells(
start_row=current_row, start_column=2, end_row=current_row, end_column=5
)
current_row += 1
version_end_row = current_row - 1
if version_end_row > version_start_row:
version_ranges.append((version_start_row, version_end_row))
if not self.no_time:
project_hours = sum(
sum(r.get("hours", 0.0) for r in task_rows) for task_rows in versions.values()
)
ws.append(
[
f"Total {project}",
"",
"",
"",
"",
project_hours,
hours_to_human(project_hours),
]
)
self._style_total_row(ws, current_row, bold=True)
ws.merge_cells(
start_row=current_row, start_column=1, end_row=current_row, end_column=5
)
current_row += 1
project_totals[project] = project_hours
project_end_row = current_row - 1
if project_end_row > project_start_row:
project_ranges.append((project_start_row, project_end_row))
if not self.no_time and project_totals:
total_hours = sum(project_totals.values())
ws.append(
[
"Total",
"",
"",
"",
"",
total_hours,
hours_to_human(total_hours),
]
)
self._style_total_row(ws, current_row, bold=True)
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=5)
for start, end in project_ranges:
ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1)
cell = ws.cell(row=start, column=1)
cell.alignment = Alignment(vertical="top", wrap_text=True)
for start, end in version_ranges:
ws.merge_cells(start_row=start, start_column=2, end_row=end, end_column=2)
cell = ws.cell(row=start, column=2)
cell.alignment = Alignment(vertical="top", wrap_text=True)
self._apply_column_widths(ws)
if not self.no_time:
self._apply_number_format(ws)
self._apply_auto_filter(ws, ws.max_row)
ws.freeze_panes = "A2"
return wb return wb
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: List[ReportRow], output_path: str) -> None:
wb = self.format(rows) wb = self.format(rows)
wb.save(output_path) wb.save(output_path)
def _style_header_row(self, ws: Worksheet, headers: List[str]) -> None:
for col_idx, _ in enumerate(headers, start=1):
cell = ws.cell(row=1, column=col_idx)
cell.font = Font(bold=True)
cell.fill = self._HEADER_FILL
cell.border = self._BORDER
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
def _style_data_row(self, ws: Worksheet, row: int) -> None:
for col_idx in range(1, 8):
cell = ws.cell(row=row, column=col_idx)
cell.border = self._BORDER
if col_idx in (1, 2):
cell.alignment = Alignment(vertical="top", wrap_text=True)
elif col_idx == 4:
cell.alignment = Alignment(vertical="top", wrap_text=True)
else:
cell.alignment = Alignment(vertical="top")
def _style_total_row(self, ws: Worksheet, row: int, bold: bool) -> None:
for col_idx in range(1, 8):
cell = ws.cell(row=row, column=col_idx)
cell.border = self._BORDER
cell.fill = self._TOTAL_FILL
cell.font = Font(bold=bold)
cell.alignment = Alignment(vertical="center")
def _apply_column_widths(self, ws: Worksheet) -> None:
# Минимальные ширины по умолчанию
widths: Dict[int, float] = {1: 18.0, 2: 16.0, 3: 12.0, 4: 45.0, 5: 14.0, 6: 10.0, 7: 14.0}
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
for col_idx, cell in enumerate(row, start=1):
if cell.value is None:
continue
text = str(cell.value)
# Оценочная ширина: примерно 1.1 символа на единицу ширины Excel
estimated = len(text) * 1.1 + 2
widths[col_idx] = max(widths[col_idx], min(estimated, 80))
for col_idx, width in widths.items():
ws.column_dimensions[get_column_letter(col_idx)].width = width
def _apply_number_format(self, ws: Worksheet) -> None:
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=6, max_col=6):
for cell in row:
if isinstance(cell.value, (int, float)):
cell.number_format = "0.00"
def _apply_auto_filter(self, ws: Worksheet, max_row: int) -> None:
ws.auto_filter.ref = f"A1:G{max_row}"

View File

@@ -208,3 +208,20 @@ def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
captured = capsys.readouterr() captured = capsys.readouterr()
assert "Total issues" not in captured.out assert "Total issues" not in captured.out
assert "Total issues" in captured.err assert "Total issues" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
"""CLI --no-time передаётся в файловый форматтер."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xlsx")
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get_formatter:
mock_formatter = mock.MagicMock()
mock_get_formatter.return_value = mock_formatter
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
_, kwargs = mock_get_formatter.call_args
assert kwargs.get("no_time") is True

View File

@@ -42,6 +42,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Реализовать фичу X", "subject": "Реализовать фичу X",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "4ч 30м", "time_text": "4ч 30м",
"hours": 4.5,
}, },
{ {
"project": "Проект A", "project": "Проект A",
@@ -52,6 +53,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Исправить баг Y", "subject": "Исправить баг Y",
"status_ru": "Решена", "status_ru": "Решена",
"time_text": "", "time_text": "",
"hours": 2.0,
}, },
{ {
"project": "Проект A", "project": "Проект A",
@@ -62,6 +64,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Документация Z", "subject": "Документация Z",
"status_ru": "Ожидание", "status_ru": "Ожидание",
"time_text": "", "time_text": "",
"hours": 1.0,
}, },
{ {
"project": "Проект B", "project": "Проект B",
@@ -72,6 +75,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Обновить README", "subject": "Обновить README",
"status_ru": "Закрыто", "status_ru": "Закрыто",
"time_text": "", "time_text": "",
"hours": 0.0,
}, },
{ {
"project": "Проект C", "project": "Проект C",
@@ -82,6 +86,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Настроить CI", "subject": "Настроить CI",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "3ч 15м", "time_text": "3ч 15м",
"hours": 3.25,
}, },
{ {
"project": "Проект C", "project": "Проект C",
@@ -92,6 +97,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Добавить тесты", "subject": "Добавить тесты",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "", "time_text": "",
"hours": 5.0,
}, },
{ {
"project": "Проект C", "project": "Проект C",
@@ -102,6 +108,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Рефакторинг", "subject": "Рефакторинг",
"status_ru": "Решена", "status_ru": "Решена",
"time_text": "6ч 45м", "time_text": "6ч 45м",
"hours": 6.75,
}, },
] ]
@@ -342,7 +349,73 @@ def test_xlsx_save_creates_valid_file(fake_rows, tmp_path):
assert ws["A1"].value == "Project" assert ws["A1"].value == "Project"
assert ws["A2"].value == "Проект A" assert ws["A2"].value == "Проект A"
assert ws["C2"].value == 101 assert ws["C2"].value == 101
assert ws.max_row == len(fake_rows) + 1 # header + data assert ws["F2"].value == 4.5
assert ws["G2"].value == "4ч 30м"
# header + 7 data rows + 5 version totals + 3 project totals + 1 grand total
assert ws.max_row == 17
def test_xlsx_has_merged_cells(fake_rows, tmp_path):
"""XLSX содержит объединённые ячейки по проектам и версиям."""
from openpyxl import load_workbook
output = tmp_path / "report.xlsx"
XLSXFormatter().save(fake_rows, str(output))
wb = load_workbook(str(output))
ws = wb.active
merged_ranges = [str(r) for r in ws.merged_cells.ranges]
# Проект A: 3 строки данных + 2 итога по версиям + 1 итог по проекту = строки 2-7
assert any("A2:A7" in r for r in merged_ranges)
# Версия v1.0 проекта A: 2 строки данных + 1 итог по версии = строки 2-4
assert any("B2:B4" in r for r in merged_ranges)
def test_xlsx_has_totals(fake_rows, tmp_path):
"""XLSX содержит итоги по версиям, проектам и общий итог."""
from openpyxl import load_workbook
output = tmp_path / "report.xlsx"
XLSXFormatter().save(fake_rows, str(output))
wb = load_workbook(str(output))
ws = wb.active
total_values = [ws.cell(row=r, column=6).value for r in range(2, ws.max_row + 1)]
# Проект A всего: 4.5 + 2 + 1 = 7.5
assert 7.5 in total_values
# Общий итог: 4.5 + 2 + 1 + 0 + 3.25 + 5 + 6.75 = 22.5
assert 22.5 in total_values
# Проверим числовой формат
assert ws["F2"].number_format == "0.00"
def test_xlsx_no_time_keeps_columns_empty_and_skips_totals(fake_rows, tmp_path):
"""XLSX с no_time: колонки времени пустые, итогов нет."""
from openpyxl import load_workbook
output = tmp_path / "report.xlsx"
XLSXFormatter(no_time=True).save(fake_rows, str(output))
wb = load_workbook(str(output))
ws = wb.active
# header + 7 data rows
assert ws.max_row == 8
# Колонки времени пустые для всех строк данных
for row in range(2, ws.max_row + 1):
assert ws.cell(row=row, column=6).value in (None, "")
assert ws.cell(row=row, column=7).value in (None, "")
# Итоговых строк нет
for row in range(2, ws.max_row + 1):
assert not str(ws.cell(row=row, column=1).value or "").startswith("Total")
assert not str(ws.cell(row=row, column=2).value or "").startswith("Total")
# Автофильтр и freeze panes на месте
assert ws.freeze_panes == "A2"
assert ws.auto_filter.ref == "A1:G8"
def test_markdown_formatter_escapes_table_cells(): def test_markdown_formatter_escapes_table_cells():