from importlib import resources from typing import List from odf.opendocument import OpenDocument, load from odf.style import Style, TableCellProperties, TableColumnProperties from odf.table import CoveredTableCell, Table, TableCell, TableColumn, TableRow from odf.text import P from ..report_builder import group_rows_by_project_and_version from ..types import ReportRow from ..utils import get_month_name_from_range from .base import Formatter class ODTFormatter(Formatter): """Форматтер для экспорта в ODT.""" def __init__(self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs): """ Инициализирует форматтер с параметрами для шапки отчета. """ self.author = author self.from_date = from_date self.to_date = to_date def format(self, rows: List[ReportRow]) -> OpenDocument: """ Форматирует данные в объект OpenDocument. """ with resources.files("redmine_reporter").joinpath("templates/template.odt").open("rb") as f: doc = load(f) # Удаляем все текстовые параграфы из шаблона, оставляя только # структурные элементы (forms, sequence-decls). Это защищает от # артефактов редактирования шаблона в LibreOffice (#42). for child in list(doc.text.childNodes): if child.tagName == "text:p": doc.text.removeChild(child) para_style_name = "Standard" # Заголовок month_name = get_month_name_from_range(self.from_date, self.to_date) if self.author: header_text = f"{self.author}. Отчет за месяц {month_name}." else: header_text = f"Отчет за месяц {month_name}." doc.text.addElement(P(stylename=para_style_name, text=header_text)) doc.text.addElement(P(stylename=para_style_name, text="")) # Стиль ячеек cell_style_name = "TableCellStyle" cell_style = Style(name=cell_style_name, family="table-cell") cell_props = TableCellProperties(padding="0.04in", border="0.05pt solid #000000") cell_style.addElement(cell_props) doc.automaticstyles.addElement(cell_style) # Таблица table = Table(name="Report") column_widths = ["1.56in", "1.63in", "3.93in", "1.56in", "1.43in"] for width in column_widths: col_style = Style(name=f"col_{width}", family="table-column") col_props = TableColumnProperties(columnwidth=width) col_style.addElement(col_props) doc.automaticstyles.addElement(col_style) table.addElement(TableColumn(stylename=col_style)) # Заголовки header_row = TableRow() for text in [ "Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период", ]: cell = TableCell(stylename=cell_style_name) cell.addElement(P(stylename=para_style_name, text=text)) header_row.addElement(cell) table.addElement(header_row) projects = group_rows_by_project_and_version(rows) # Данные с двухуровневой группировкой и объединением ячеек for project, versions in projects.items(): total_project_rows = sum( len(rows_for_version) for rows_for_version in versions.values() ) first_version_in_project = True for version, rows_for_version in versions.items(): row_span_version = len(rows_for_version) first_row_in_version = True for r in rows_for_version: row = TableRow() # Ячейка "Проект" - только в первой строке всего проекта, # в остальных — covered-cell для валидности ODF (#13) if first_version_in_project and first_row_in_version: cell_project = TableCell(stylename=cell_style_name) cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) p = P(stylename=para_style_name, text=project) cell_project.addElement(p) row.addElement(cell_project) else: row.addElement(CoveredTableCell()) # Ячейка "Версия" - только в первой строке каждой версии, # в остальных — covered-cell для валидности ODF (#13) if first_row_in_version: cell_version = TableCell(stylename=cell_style_name) cell_version.setAttribute("numberrowsspanned", str(row_span_version)) p = P(stylename=para_style_name, text=version) cell_version.addElement(p) row.addElement(cell_version) first_row_in_version = False else: row.addElement(CoveredTableCell()) # Остальные колонки task_cell = TableCell(stylename=cell_style_name) task_text = f"{r['issue_id']}. {r['subject']}" p = P(stylename=para_style_name, text=task_text) task_cell.addElement(p) row.addElement(task_cell) status_cell = TableCell(stylename=cell_style_name) p = P(stylename=para_style_name, text=r["status_ru"]) status_cell.addElement(p) row.addElement(status_cell) time_cell = TableCell(stylename=cell_style_name) time_lines = r["time_text"].split("\n") for i, line in enumerate(time_lines): p = P(stylename=para_style_name, text=line) time_cell.addElement(p) if i < len(time_lines) - 1: time_cell.addElement(P(stylename=para_style_name, text="")) row.addElement(time_cell) table.addElement(row) first_version_in_project = False doc.text.addElement(table) doc.text.addElement(P(stylename=para_style_name, text="")) # Справка for line in [ "«Наименование Проекта» - Имя собственное устройства или программного обеспечения.", "«Номер версии» - Версия в проекте. Опциональное поле.", "«Задача» - Номер по Redmine и формулировка.", "«Статус» - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена.", "«Готовность» – Опциональное поле в процентах.", "«Затрачено за отчетный период» - в днях или часах.", ]: doc.text.addElement(P(stylename=para_style_name, text=line)) return doc def save(self, rows: List[ReportRow], output_path: str) -> None: """ Сохраняет сформированный документ в файл. """ doc = self.format(rows) doc.save(output_path)