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

181 lines
8.0 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 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)