Files
redmine-reporter/redmine_reporter/formatters/xlsx.py
Кокос Артем Николаевич 0968560090 style: apply ruff format to all source files
2026-07-10 12:39:24 +07:00

246 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Dict, List, Tuple
from openpyxl import Workbook
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).
Использует группировку по проекту и версии: объединяет ячейки,
добавляет итоги по группам, закрепляет заголовок, включает автофильтр
и числовой столбец с часами для удобного суммирования.
"""
_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",
"Hours",
"Spent Time",
]
ws.append(headers)
self._style_header_row(ws, headers)
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(
[
"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}"