From bca24189c7452d340de17fb7c3dccb1bdfb3048b Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Tue, 20 Jan 2026 23:25:08 +0700 Subject: [PATCH] ODT table support --- pyproject.toml | 1 + redmine_reporter/cli.py | 38 ++++++++++++---- redmine_reporter/formatter_odt.py | 72 ++++++++++++++++++++++++++++++ tests/test_formatter_odt.py | 73 +++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 redmine_reporter/formatter_odt.py create mode 100644 tests/test_formatter_odt.py diff --git a/pyproject.toml b/pyproject.toml index 428cc48..37d4639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "python-redmine>=2.4.0", "tabulate>=0.9.0", "python-dotenv>=1.0.0", + "odfpy>=1.4.0", ] [project.optional-dependencies] diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 936f2fd..b974ac8 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -32,6 +32,10 @@ def main(argv: Optional[List[str]] = None) -> int: action="store_true", help="Use compact plain-text output instead of table" ) + parser.add_argument( + "--output", + help="Path to output .odt file (e.g., report.odt). If omitted, prints to stdout." + ) args = parser.parse_args(argv) try: @@ -58,15 +62,31 @@ def main(argv: Optional[List[str]] = None) -> int: print(f"✅ Total issues: {len(issue_hours)} [{args.date}]") - try: - if args.compact: - output = format_compact(issue_hours) - else: - output = format_table(issue_hours) - print(output) - except Exception as e: - print(f"❌ Formatting error: {e}", file=sys.stderr) - return 1 + if args.output: + if not args.output.endswith(".odt"): + print("❌ Output file must end with .odt", file=sys.stderr) + return 1 + try: + from .formatter_odt import format_odt + doc = format_odt(issue_hours) + doc.save(args.output) + print(f"✅ Report saved to {args.output}") + except ImportError: + print("❌ odfpy is not installed. Install with: pip install odfpy", file=sys.stderr) + return 1 + except Exception as e: + print(f"❌ ODT export error: {e}", file=sys.stderr) + return 1 + else: + try: + if args.compact: + output = format_compact(issue_hours) + else: + output = format_table(issue_hours) + print(output) + except Exception as e: + print(f"❌ Formatting error: {e}", file=sys.stderr) + return 1 return 0 diff --git a/redmine_reporter/formatter_odt.py b/redmine_reporter/formatter_odt.py new file mode 100644 index 0000000..b6b802a --- /dev/null +++ b/redmine_reporter/formatter_odt.py @@ -0,0 +1,72 @@ +from typing import List, Tuple +from redminelib.resources import Issue +from odf.opendocument import OpenDocumentText +from odf.style import Style, TableProperties, TableCellProperties, ParagraphProperties +from odf.text import P +from odf.table import Table, TableColumn, TableRow, TableCell + +from .formatter import get_version, hours_to_human, STATUS_TRANSLATION + + +def format_odt(issue_hours: List[Tuple[Issue, float]]) -> OpenDocumentText: + doc = OpenDocumentText() + + # Стили + table_style = Style(name="Table", family="table") + table_style.addElement(TableProperties(width="17cm", align="center")) + doc.styles.addElement(table_style) + + cell_style = Style(name="Cell", family="table-cell") + cell_style.addElement(TableCellProperties(border="0.5pt solid #000000")) + doc.styles.addElement(cell_style) + + para_style = Style(name="Para", family="paragraph") + para_style.addElement(ParagraphProperties(textalign="left")) + doc.styles.addElement(para_style) + + # Таблица + table = Table(name="Report", stylename=table_style) + for _ in range(5): # 5 колонок + table.addElement(TableColumn()) + + # Заголовок + header_row = TableRow() + headers = ["Проект", "Версия", "Задача", "Статус", "Затрачено"] + for text in headers: + cell = TableCell(stylename=cell_style) + p = P(stylename=para_style, text=text) + cell.addElement(p) + header_row.addElement(cell) + table.addElement(header_row) + + # Данные + prev_project = None + prev_version = None + for issue, hours in issue_hours: + project = str(issue.project) + version = get_version(issue) + status_en = str(issue.status) + status_ru = STATUS_TRANSLATION.get(status_en, status_en) + + display_project = project if project != prev_project else "" + display_version = version if (project != prev_project or version != prev_version) else "" + + row = TableRow() + for col_text in [ + display_project, + display_version, + f"{issue.id}. {issue.subject}", + status_ru, + hours_to_human(hours) + ]: + cell = TableCell(stylename=cell_style) + p = P(stylename=para_style, text=col_text) + cell.addElement(p) + row.addElement(cell) + table.addElement(row) + + prev_project = project + prev_version = version + + doc.text.addElement(table) + return doc diff --git a/tests/test_formatter_odt.py b/tests/test_formatter_odt.py new file mode 100644 index 0000000..9cd5bb9 --- /dev/null +++ b/tests/test_formatter_odt.py @@ -0,0 +1,73 @@ +import tempfile +from types import SimpleNamespace +from redmine_reporter.formatter_odt import format_odt + + +def make_mock_issue(id_, project, subject, status, fixed_version=None): + """Создаёт лёгкий mock-объект, имитирующий Issue из redminelib.""" + + issue = SimpleNamespace() + issue.id = id_ + issue.project = project + issue.subject = subject + issue.status = status + + if fixed_version is not None: + issue.fixed_version = fixed_version + return issue + + +def test_format_odt_basic(): + issues = [ + (make_mock_issue(101, "Камеры", "Поддержка нового датчика", "In Progress", "v2.5.0"), 2.5), + (make_mock_issue(102, "Камеры", "Исправить утечку памяти", "Resolved", "v2.5.0"), 4.0), + (make_mock_issue(103, "ПО", "Обновить документацию", "Pending", None), 12.0), + ] + + doc = format_odt(issues) + + # Сохраняем и проверяем содержимое + with tempfile.NamedTemporaryFile(suffix=".odt") as tmp: + doc.save(tmp.name) + + # Проверяем, что файл - это ZIP (ODT основан на ZIP) + with open(tmp.name, "rb") as f: + assert f.read(2) == b"PK" + + # Извлекаем content.xml + import zipfile + with zipfile.ZipFile(tmp.name) as zf: + content_xml = zf.read("content.xml").decode("utf-8") + + # Проверяем заголовки + assert "Проект" in content_xml + assert "Версия" in content_xml + assert "Задача" in content_xml + assert "Статус" in content_xml + assert "Затрачено" in content_xml + + # Проверяем данные задач + assert "101. Поддержка нового датчика" in content_xml + assert "102. Исправить утечку памяти" in content_xml + assert "103. Обновить документацию" in content_xml + + # Проверяем проекты и версии + assert "Камеры" in content_xml + assert "ПО" in content_xml + assert "v2.5.0" in content_xml + assert "<N/A>" in content_xml or "" in content_xml # зависит от экранирования + + # Проверяем перевод статусов + assert "В работе" in content_xml # In Progress + assert "Решена" in content_xml # Resolved + assert "Ожидание" in content_xml # Pending + + # Проверяем формат времени + assert "2ч 30м" in content_xml + assert "4ч" in content_xml + assert "12ч" in content_xml + + # Проверяем группировку: "Камеры" должен встречаться только один раз явно + # (вторая строка — пустая ячейка) + cam_occurrences = content_xml.count(">Камеры<") + assert cam_occurrences == 1