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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -88,7 +88,7 @@ secrets.json
|
||||
|
||||
# Just in case
|
||||
.~*
|
||||
report.odt
|
||||
report.csv
|
||||
report.md
|
||||
# Local report outputs
|
||||
report.*
|
||||
rep.*
|
||||
*.html
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.6.0"
|
||||
__version__ = "1.6.1"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
data = [
|
||||
r["project"],
|
||||
r["version"],
|
||||
r["issue_id"],
|
||||
r["subject"],
|
||||
r["status_ru"],
|
||||
r["time_text"],
|
||||
]
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
Инициализирует форматтер с параметрами для шапки отчета.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
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(
|
||||
[
|
||||
r["project"],
|
||||
r["version"],
|
||||
project,
|
||||
version,
|
||||
r["issue_id"],
|
||||
r["subject"],
|
||||
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
|
||||
|
||||
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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "2ч",
|
||||
"hours": 2.0,
|
||||
},
|
||||
{
|
||||
"project": "Проект A",
|
||||
@@ -62,6 +64,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
||||
"subject": "Документация Z",
|
||||
"status_ru": "Ожидание",
|
||||
"time_text": "1ч",
|
||||
"hours": 1.0,
|
||||
},
|
||||
{
|
||||
"project": "Проект B",
|
||||
@@ -72,6 +75,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
||||
"subject": "Обновить README",
|
||||
"status_ru": "Закрыто",
|
||||
"time_text": "0ч",
|
||||
"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": "5ч",
|
||||
"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():
|
||||
|
||||
Reference in New Issue
Block a user