1 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
222d31730e 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
2026-06-29 14:41:37 +07:00
8 changed files with 300 additions and 33 deletions

6
.gitignore vendored
View File

@@ -88,7 +88,7 @@ secrets.json
# Just in case
.~*
report.odt
report.csv
report.md
# Local report outputs
report.*
rep.*
*.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),
from_date=from_date,
to_date=to_date,
no_time=args.no_time,
)
if not formatter:

View File

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

View File

@@ -15,7 +15,7 @@ from .base import Formatter
class ODTFormatter(Formatter):
"""Форматтер для экспорта в 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.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 ..utils import hours_to_human
from .base import 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__()
self.no_time = no_time
def format(self, rows: List[ReportRow]) -> Workbook:
wb = Workbook()
ws = wb.active
ws.title = "Report"
if ws is None:
ws = wb.create_sheet("Report")
else:
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)
for cell in ws[1]:
cell.font = Font(bold=True)
self._style_header_row(ws, headers)
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(
[
project,
version,
r["issue_id"],
r["subject"],
r["status_ru"],
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(
[
r["project"],
r["version"],
r["issue_id"],
r["subject"],
r["status_ru"],
r["time_text"],
"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
def save(self, rows: List[ReportRow], output_path: str) -> None:
wb = self.format(rows)
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()
assert "Total issues" not in captured.out
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",
"status_ru": "В работе",
"time_text": "4ч 30м",
"hours": 4.5,
},
{
"project": "Проект A",
@@ -52,6 +53,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Исправить баг Y",
"status_ru": "Решена",
"time_text": "",
"hours": 2.0,
},
{
"project": "Проект A",
@@ -62,6 +64,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Документация Z",
"status_ru": "Ожидание",
"time_text": "",
"hours": 1.0,
},
{
"project": "Проект B",
@@ -72,6 +75,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Обновить README",
"status_ru": "Закрыто",
"time_text": "",
"hours": 0.0,
},
{
"project": "Проект C",
@@ -82,6 +86,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Настроить CI",
"status_ru": "В работе",
"time_text": "3ч 15м",
"hours": 3.25,
},
{
"project": "Проект C",
@@ -92,6 +97,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Добавить тесты",
"status_ru": "В работе",
"time_text": "",
"hours": 5.0,
},
{
"project": "Проект C",
@@ -102,6 +108,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Рефакторинг",
"status_ru": "Решена",
"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["A2"].value == "Проект A"
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():