From 222d31730e29261c9d54e452743a6c4ab8da3a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Mon, 29 Jun 2026 14:41:03 +0700 Subject: [PATCH] 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 --- .gitignore | 6 +- redmine_reporter/__init__.py | 2 +- redmine_reporter/cli.py | 1 + redmine_reporter/formatters/csv.py | 28 ++-- redmine_reporter/formatters/odt.py | 2 +- redmine_reporter/formatters/xlsx.py | 202 +++++++++++++++++++++++++--- tests/test_cli.py | 17 +++ tests/test_formatters.py | 75 ++++++++++- 8 files changed, 300 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index a3b83b0..c227679 100644 --- a/.gitignore +++ b/.gitignore @@ -88,7 +88,7 @@ secrets.json # Just in case .~* -report.odt -report.csv -report.md +# Local report outputs +report.* +rep.* *.html diff --git a/redmine_reporter/__init__.py b/redmine_reporter/__init__.py index e4adfb8..f49459c 100644 --- a/redmine_reporter/__init__.py +++ b/redmine_reporter/__init__.py @@ -1 +1 @@ -__version__ = "1.6.0" +__version__ = "1.6.1" diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 81e8a5c..8a76984 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -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: diff --git a/redmine_reporter/formatters/csv.py b/redmine_reporter/formatters/csv.py index b157c77..1a0317b 100644 --- a/redmine_reporter/formatters/csv.py +++ b/redmine_reporter/formatters/csv.py @@ -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: diff --git a/redmine_reporter/formatters/odt.py b/redmine_reporter/formatters/odt.py index 9bea76a..ddbf5e4 100644 --- a/redmine_reporter/formatters/odt.py +++ b/redmine_reporter/formatters/odt.py @@ -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): """ Инициализирует форматтер с параметрами для шапки отчета. """ diff --git a/redmine_reporter/formatters/xlsx.py b/redmine_reporter/formatters/xlsx.py index 1a99132..08824b7 100644 --- a/redmine_reporter/formatters/xlsx.py +++ b/redmine_reporter/formatters/xlsx.py @@ -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}" diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c7c617..a0a267b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_formatters.py b/tests/test_formatters.py index f08ba77..7bd1578 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -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():