diff --git a/README.md b/README.md index 2af9da1..27004b4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - Перевод статусов на русский язык - Простой CLI с понятными аргументами - Поддержка настройки диапазона дат по умолчанию через `.env` +- Экспорт в ODT с автоматическим заголовком (автор + месяц) --- @@ -59,6 +60,7 @@ cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi) REDMINE_URL=https://red.eltex.loc/ REDMINE_USER=ваш.логин REDMINE_PASSWORD=ваш_пароль +REDMINE_AUTHOR=Иванов Иван Иванович # Опционально: диапазон дат по умолчанию DEFAULT_FROM_DATE=2026-01-01 @@ -71,6 +73,7 @@ DEFAULT_TO_DATE=2026-01-31 export REDMINE_URL=https://red.eltex.loc/ export REDMINE_USER=ваш.логин export REDMINE_PASSWORD=... +export REDMINE_AUTHOR="Иванов Иван Иванович" export DEFAULT_FROM_DATE=2026-01-01 export DEFAULT_TO_DATE=2026-01-31 ``` @@ -99,9 +102,32 @@ redmine-reporter --date 2026-02-01--2026-02-28 # Компактный вывод (удобно копировать в письмо) redmine-reporter --compact + +# Экспорт в ODT с указанием автора (если не задано в .env) +redmine-reporter --output report.odt --author "Иванов Иван Иванович" ``` -Пример вывода: +> 💡 **Автоматика в ODT-отчёте**: +> - Месяц в заголовке определяется **автоматически** по дате окончания периода (`to_date`). +> Например: `2025-12-20--2026-01-15` → **«Январь»**. +> - Имя автора берётся из переменной окружения `REDMINE_AUTHOR` (в `.env`) или CLI-аргумента `--author`. +> - Первая пустая строка из шаблона `template.odt` **автоматически удаляется**. + +Пример содержимого `.env` с автором: + +```ini +REDMINE_URL=https://red.eltex.loc/ +REDMINE_USER=ваш.логин +REDMINE_PASSWORD=ваш_пароль +REDMINE_AUTHOR=Иванов Иван Иванович +DEFAULT_FROM_DATE=2026-01-01 +DEFAULT_TO_DATE=2026-01-31 +``` + +Пример вывода в ODT (заголовок): +> **Иванов Иван Иванович. Отчёт за месяц Январь.** + +Пример консольного вывода: ``` ✅ Total issues: 7 [2026-01-01--2026-01-31] ╒════════════╤═══════════╤══════════════════════════════════════╤═══════════╤════════════╕ @@ -129,6 +155,6 @@ isort . --- > 🔒 **Важно**: -> - Никогда не коммитьте `.env`, пароли или логины. +> - Никогда не коммитьте `.env`, пароли или логины. > - Файл `.gitignore` уже исключает все чувствительные артефакты. > - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine. 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..b10f1c2 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -2,9 +2,11 @@ import sys import argparse from typing import List, Optional from redminelib.resources import Issue + from .config import Config from .client import fetch_issues_with_spent_time from .formatter import format_compact, format_table +from .formatter_odt import format_odt def parse_date_range(date_arg: str) -> tuple[str, str]: @@ -32,6 +34,15 @@ 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." + ) + parser.add_argument( + "--author", + default="", + help="Override author name from .env (REDMINE_AUTHOR)" + ) args = parser.parse_args(argv) try: @@ -58,15 +69,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: + author = Config.get_author(args.author) + doc = format_odt(issue_hours, author=author, from_date=from_date, to_date=to_date) + 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/config.py b/redmine_reporter/config.py index b943837..e029256 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -9,9 +9,19 @@ class Config: REDMINE_URL = os.getenv("REDMINE_URL", "").rstrip("/") REDMINE_USER = os.getenv("REDMINE_USER") REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD") + REDMINE_AUTHOR = os.getenv("REDMINE_AUTHOR") DEFAULT_FROM_DATE = os.getenv("DEFAULT_FROM_DATE") DEFAULT_TO_DATE = os.getenv("DEFAULT_TO_DATE") + @classmethod + def get_author(cls, cli_author: str = "") -> str: + """Возвращает автора: из CLI если задан, иначе из .env, иначе — заглушку.""" + if cli_author: + return cli_author + if cls.REDMINE_AUTHOR: + return cls.REDMINE_AUTHOR + return "" + @classmethod def get_default_date_range(cls) -> str: if cls.DEFAULT_FROM_DATE and cls.DEFAULT_TO_DATE: diff --git a/redmine_reporter/formatter_odt.py b/redmine_reporter/formatter_odt.py new file mode 100644 index 0000000..17294f7 --- /dev/null +++ b/redmine_reporter/formatter_odt.py @@ -0,0 +1,113 @@ +import os +from typing import List, Tuple +from redminelib.resources import Issue +from odf.opendocument import load +from odf.text import P +from odf.table import Table, TableColumn, TableRow, TableCell + +from .formatter import get_version, hours_to_human, STATUS_TRANSLATION +from .utils import get_month_name_from_range + + +def format_odt( + issue_hours: List[Tuple[Issue, float]], + author: str = "", + from_date: str = "", + to_date: str = "" +) -> "OpenDocument": + template_path = "template.odt" + if not os.path.exists(template_path): + raise FileNotFoundError("Шаблон template.odt не найден...") + + doc = load(template_path) + para_style_name = "Standard" + + # Заголовок + month_name = get_month_name_from_range(from_date, to_date) + header_text = f"{author}. Отчет за месяц {month_name}." + header_paragraph = P(stylename=para_style_name, text=header_text) + doc.text.addElement(header_paragraph) + + # Группировка: project - version - [(issue, hours, status_ru)] + projects = {} + 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) + + if project not in projects: + projects[project] = {} + if version not in projects[project]: + projects[project][version] = [] + projects[project][version].append((issue, hours, status_ru)) + + # Создаём таблицу + table = Table(name="Report") + for _ in range(5): + table.addElement(TableColumn()) + + # Заголовки + header_row = TableRow() + headers = ["Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период"] + for text in headers: + cell = TableCell() + p = P(stylename=para_style_name, text=text) + cell.addElement(p) + header_row.addElement(cell) + table.addElement(header_row) + + # Данные с двухуровневой группировкой и объединением ячеек + for project, versions in projects.items(): + total_project_rows = sum(len(rows) for rows in versions.values()) + first_version_in_project = True + + for version, rows in versions.items(): + row_span_version = len(rows) + first_row_in_version = True + + for issue, hours, status_ru in rows: + row = TableRow() + + # Ячейка "Проект" - только в первой строке всего проекта + if first_version_in_project and first_row_in_version: + cell_project = TableCell() + cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) + p = P(stylename=para_style_name, text=project) + cell_project.addElement(p) + row.addElement(cell_project) + + # Ячейка "Версия" - только в первой строке каждой версии + if first_row_in_version: + cell_version = TableCell() + 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: + # Пропускаем - уже объединена + pass + + # Остальные колонки + task_cell = TableCell() + p = P(stylename=para_style_name, text=f"{issue.id}. {issue.subject}") + task_cell.addElement(p) + row.addElement(task_cell) + + status_cell = TableCell() + p = P(stylename=para_style_name, text=status_ru) + status_cell.addElement(p) + row.addElement(status_cell) + + time_cell = TableCell() + p = P(stylename=para_style_name, text=hours_to_human(hours)) + time_cell.addElement(p) + row.addElement(time_cell) + + table.addElement(row) + + first_version_in_project = False + + doc.text.addElement(table) + return doc diff --git a/redmine_reporter/utils.py b/redmine_reporter/utils.py index 3d44ad2..77696b5 100644 --- a/redmine_reporter/utils.py +++ b/redmine_reporter/utils.py @@ -1,2 +1,23 @@ +from datetime import datetime + + +def get_month_name_from_range(from_date: str, to_date: str) -> str: + """Определяет название месяца по диапазону дат. + - Если from == to - возвращает месяц этой даты. + - Если диапазон охватывает несколько месяцев - возвращает месяц из to_date. + """ + + try: + end = datetime.strptime(to_date, "%Y-%m-%d") + except ValueError: + return "Январь" # fallback, хотя лучше бы не срабатывало + + months = [ + "", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", + "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" + ] + return months[end.month] + + def get_version(issue) -> str: return str(getattr(issue, 'fixed_version', '')) diff --git a/template.odt b/template.odt new file mode 100644 index 0000000..214534e Binary files /dev/null and b/template.odt differ 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