feat: JSON, Excel export and time summary

- #23: add JSONFormatter and XLSXFormatter
- add openpyxl dependency for .xlsx export
- #22: add --summary flag and calculate_summary() in report_builder
- ReportRow now carries raw hours for summary calculations
- update CLI help and README with .json/.xlsx formats and --summary
- add tests for new formatters and summary computation

Closes #22, closes #23
This commit is contained in:
Кокос Артем Николаевич
2026-06-29 12:09:58 +07:00
parent f6afc4096d
commit ca89832d74
11 changed files with 255 additions and 7 deletions

View File

@@ -14,11 +14,13 @@ CLI-инструмент для генерации отчётов по зада
- Группировка задач по проекту и версии. - Группировка задач по проекту и версии.
- Перевод статусов задач на русский язык. - Перевод статусов задач на русский язык.
- Вывод в консоль в табличном или компактном виде. - Вывод в консоль в табличном или компактном виде.
- Экспорт в ODT, CSV, Markdown и HTML. - Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
- Сводка по затраченному времени (итоги и разбивка по проектам/версиям).
- Автоматическое определение месяца ODT-отчёта по конечной дате периода. - Автоматическое определение месяца ODT-отчёта по конечной дате периода.
- Настройка периода отчёта по умолчанию через `.env` (или автоматически — текущий месяц). - Настройка периода отчёта по умолчанию через `.env` (или автоматически — текущий месяц).
- Переопределение URL и API-ключа через CLI. - Переопределение URL и API-ключа через CLI.
- Загрузка альтернативного `.env` через `--config`. - Загрузка альтернативного `.env` через `--config`.
- Загрузка альтернативного `.env` через `--config`.
## Установка ## Установка
@@ -140,8 +142,21 @@ redmine-reporter --output report.odt
redmine-reporter --output report.csv redmine-reporter --output report.csv
redmine-reporter --output report.md redmine-reporter --output report.md
redmine-reporter --output report.html redmine-reporter --output report.html
redmine-reporter --output report.json
redmine-reporter --output report.xlsx
``` ```
JSON-отчёт:
- массив объектов с полями `project`, `version`, `issue_id`, `subject`, `status`, `time`;
- UTF-8, читаемый машиной.
Excel-отчёт (.xlsx):
- одна таблица с заголовками и строками данных;
- шапка выделена жирным;
- каждая строка содержит полные значения проекта и версии.
CSV-отчёт: CSV-отчёт:
- файл сохраняется в UTF-8 с BOM (`utf-8-sig`) для корректного отображения кириллицы в Microsoft Excel; - файл сохраняется в UTF-8 с BOM (`utf-8-sig`) для корректного отображения кириллицы в Microsoft Excel;
@@ -164,6 +179,12 @@ ODT-отчёт:
redmine-reporter --no-time redmine-reporter --no-time
``` ```
Сводка по времени (итоги и разбивка по проектам):
```bash
redmine-reporter --summary
```
## Разработка ## Разработка
Установка зависимостей для разработки: Установка зависимостей для разработки:

View File

@@ -23,6 +23,7 @@ dependencies = [
"tabulate>=0.9.0", "tabulate>=0.9.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"odfpy>=1.4.0", "odfpy>=1.4.0",
"openpyxl>=3.1.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -10,7 +10,7 @@ from . import __version__
from .client import fetch_issues_with_spent_time from .client import fetch_issues_with_spent_time
from .config import Config from .config import Config
from .formatters.factory import get_console_formatter, get_formatter_by_extension from .formatters.factory import get_console_formatter, get_formatter_by_extension
from .report_builder import build_grouped_report from .report_builder import build_grouped_report, calculate_summary
def parse_date_range(date_arg: str) -> tuple[str, str]: def parse_date_range(date_arg: str) -> tuple[str, str]:
@@ -52,7 +52,7 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
parser.add_argument( parser.add_argument(
"--output", "--output",
help="Path to output file (.odt, .csv, .md, .html). If omitted, prints to stdout.", help="Path to output file (.odt, .csv, .md, .html, .json, .xlsx). If omitted, prints to stdout.",
) )
parser.add_argument( parser.add_argument(
"--author", default="", help="Override author name from .env (REDMINE_AUTHOR)" "--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"
@@ -71,6 +71,11 @@ def main(argv: Optional[List[str]] = None) -> int:
version=f"%(prog)s {__version__}", version=f"%(prog)s {__version__}",
help="Show version and exit", help="Show version and exit",
) )
parser.add_argument(
"--summary",
action="store_true",
help="Print summary (total hours by project/version) to stderr",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
# CLI-переопределения имеют приоритет над .env/env. # CLI-переопределения имеют приоритет над .env/env.
@@ -113,12 +118,20 @@ def main(argv: Optional[List[str]] = None) -> int:
rows = build_grouped_report(issue_hours, fill_time=not args.no_time) rows = build_grouped_report(issue_hours, fill_time=not args.no_time)
if args.summary:
summary = calculate_summary(rows)
print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr)
for key, value in summary.items():
if key.startswith("project:"):
project = key.split(":", 1)[1]
print(f" {project}: {value}h", file=sys.stderr)
if args.output: if args.output:
output_ext = os.path.splitext(args.output)[1].lower() output_ext = os.path.splitext(args.output)[1].lower()
if not output_ext: if not output_ext:
print( print(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md или .html", "❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1
@@ -137,7 +150,7 @@ def main(argv: Optional[List[str]] = None) -> int:
file=sys.stderr, file=sys.stderr,
) )
else: else:
known_exts = ", ".join([".odt", ".csv", ".md", ".html"]) known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
print( print(
f"❌ Неизвестный формат файла: {output_ext!r}. " f"❌ Неизвестный формат файла: {output_ext!r}. "
f"Поддерживаются: {known_exts}", f"Поддерживаются: {known_exts}",

View File

@@ -4,15 +4,19 @@ from .base import Formatter
from .console import CompactFormatter, TableFormatter from .console import CompactFormatter, TableFormatter
from .csv import CSVFormatter from .csv import CSVFormatter
from .html import HTMLFormatter from .html import HTMLFormatter
from .json import JSONFormatter
from .markdown import MarkdownFormatter from .markdown import MarkdownFormatter
from .xlsx import XLSXFormatter
# Словарь для сопоставления расширений файлов с классами форматтеров. # Словарь для сопоставления расширений файлов с классами форматтеров.
# ODT намеренно отсутствует — его импорт отложен (ленивый), так как odfpy # ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
# может быть не установлен. См. get_formatter_by_extension. # odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
FORMATTER_MAP: Dict[str, Type[Formatter]] = { FORMATTER_MAP: Dict[str, Type[Formatter]] = {
".csv": CSVFormatter, ".csv": CSVFormatter,
".json": JSONFormatter,
".md": MarkdownFormatter, ".md": MarkdownFormatter,
".html": HTMLFormatter, ".html": HTMLFormatter,
".xlsx": XLSXFormatter,
} }

View File

@@ -0,0 +1,31 @@
import json
from typing import List
from ..types import ReportRow
from .base import Formatter
class JSONFormatter(Formatter):
"""Форматтер для экспорта отчёта в JSON."""
def __init__(self, **_kwargs):
super().__init__()
def format(self, rows: List[ReportRow]) -> str:
data = [
{
"project": r["project"],
"version": r["version"],
"issue_id": r["issue_id"],
"subject": r["subject"],
"status": r["status_ru"],
"time": r["time_text"],
}
for r in rows
]
return json.dumps(data, ensure_ascii=False, indent=2)
def save(self, rows: List[ReportRow], output_path: str) -> None:
content = self.format(rows)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)

View File

@@ -0,0 +1,42 @@
from typing import List
from openpyxl import Workbook
from openpyxl.styles import Font
from ..types import ReportRow
from .base import Formatter
class XLSXFormatter(Formatter):
"""Форматтер для экспорта отчёта в Excel (.xlsx)."""
def __init__(self, **_kwargs):
super().__init__()
def format(self, rows: List[ReportRow]) -> Workbook:
wb = Workbook()
ws = wb.active
ws.title = "Report"
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]
ws.append(headers)
for cell in ws[1]:
cell.font = Font(bold=True)
for r in rows:
ws.append(
[
r["project"],
r["version"],
r["issue_id"],
r["subject"],
r["status_ru"],
r["time_text"],
]
)
return wb
def save(self, rows: List[ReportRow], output_path: str) -> None:
wb = self.format(rows)
wb.save(output_path)

View File

@@ -63,6 +63,7 @@ def build_grouped_report(
"subject": issue.subject, "subject": issue.subject,
"status_ru": status_ru, "status_ru": status_ru,
"time_text": time_text, "time_text": time_text,
"hours": round(hours, 2),
}, },
) )
) )
@@ -73,6 +74,26 @@ def build_grouped_report(
return rows return rows
def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]:
"""Возвращает сводку: общее время, время по проектам и версиям."""
total = 0.0
by_project: Dict[str, float] = {}
by_project_version: Dict[str, float] = {}
for r in rows:
hours = r.get("hours", 0.0)
total += hours
by_project[r["project"]] = by_project.get(r["project"], 0.0) + hours
key = f"{r['project']}::{r['version']}"
by_project_version[key] = by_project_version.get(key, 0.0) + hours
return {
"total": round(total, 2),
**{f"project:{k}": round(v, 2) for k, v in by_project.items()},
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
}
def group_rows_by_project_and_version( def group_rows_by_project_and_version(
rows: List[ReportRow], rows: List[ReportRow],
) -> Dict[str, Dict[str, List[ReportRow]]]: ) -> Dict[str, Dict[str, List[ReportRow]]]:

View File

@@ -12,3 +12,4 @@ class ReportRow(TypedDict):
subject: str subject: str
status_ru: str status_ru: str
time_text: str time_text: str
hours: float

View File

@@ -170,6 +170,18 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
assert code == 0 assert code == 0
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
"""--summary выводит общее время и разбивку по проектам в stderr."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
main(["--date", "2026-01-01--2026-01-31", "--summary"])
captured = capsys.readouterr()
assert "Total time" in captured.err
assert "Project" in captured.err or "Project" in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys): def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):

View File

@@ -1,4 +1,5 @@
import io import io
import json
from typing import List from typing import List
from unittest import mock from unittest import mock
@@ -7,9 +8,12 @@ from odf.opendocument import OpenDocument, OpenDocumentText
from redmine_reporter.formatters.console import CompactFormatter, TableFormatter from redmine_reporter.formatters.console import CompactFormatter, TableFormatter
from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.csv import CSVFormatter
from redmine_reporter.formatters.factory import get_formatter_by_extension
from redmine_reporter.formatters.html import HTMLFormatter from redmine_reporter.formatters.html import HTMLFormatter
from redmine_reporter.formatters.json import JSONFormatter
from redmine_reporter.formatters.markdown import MarkdownFormatter from redmine_reporter.formatters.markdown import MarkdownFormatter
from redmine_reporter.formatters.odt import ODTFormatter from redmine_reporter.formatters.odt import ODTFormatter
from redmine_reporter.formatters.xlsx import XLSXFormatter
from redmine_reporter.types import ReportRow from redmine_reporter.types import ReportRow
@@ -223,6 +227,7 @@ TEXT_FORMATTER_FACTORIES = [
("table", lambda: TableFormatter()), ("table", lambda: TableFormatter()),
("compact", lambda: CompactFormatter()), ("compact", lambda: CompactFormatter()),
("csv", lambda: CSVFormatter()), ("csv", lambda: CSVFormatter()),
("json", lambda: JSONFormatter()),
("markdown", lambda: MarkdownFormatter()), ("markdown", lambda: MarkdownFormatter()),
] ]
@@ -250,6 +255,10 @@ def test_text_formatter_contains_key_content(fake_rows, name, factory):
# В CSV issue_id и subject -- отдельные колонки # В CSV issue_id и subject -- отдельные колонки
assert "101" in output assert "101" in output
assert "Реализовать фичу X" in output assert "Реализовать фичу X" in output
elif name == "json":
# В JSON issue_id -- число, subject -- отдельное поле
assert '"issue_id": 101' in output
assert "Реализовать фичу X" in output
else: else:
assert "101. Реализовать фичу X" in output assert "101. Реализовать фичу X" in output
@@ -298,6 +307,44 @@ def test_csv_uses_full_values_not_display(fake_rows):
assert "v1.0" in lines[2] assert "v1.0" in lines[2]
def test_json_save_writes_parsable_data(fake_rows, tmp_path):
"""JSON-файл содержит валидный JSON со всеми строками отчёта."""
output = tmp_path / "report.json"
JSONFormatter().save(fake_rows, str(output))
data = json.loads(output.read_text(encoding="utf-8"))
assert len(data) == len(fake_rows)
assert data[0]["project"] == "Проект A"
assert data[0]["issue_id"] == 101
def test_get_formatter_by_extension_json():
"""get_formatter_by_extension('.json') возвращает JSONFormatter."""
formatter = get_formatter_by_extension(".json")
assert isinstance(formatter, JSONFormatter)
def test_get_formatter_by_extension_xlsx():
"""get_formatter_by_extension('.xlsx') возвращает XLSXFormatter."""
formatter = get_formatter_by_extension(".xlsx")
assert isinstance(formatter, XLSXFormatter)
def test_xlsx_save_creates_valid_file(fake_rows, tmp_path):
"""XLSX-файл сохраняется и содержит корректные данные."""
from openpyxl import load_workbook
output = tmp_path / "report.xlsx"
XLSXFormatter().save(fake_rows, str(output))
wb = load_workbook(str(output))
ws = wb.active
assert ws.title == "Report"
assert ws["A1"].value == "Project"
assert ws["A2"].value == "Проект A"
assert ws["C2"].value == 101
assert ws.max_row == len(fake_rows) + 1 # header + data
def test_markdown_formatter_escapes_table_cells(): def test_markdown_formatter_escapes_table_cells():
rows = make_fake_report_rows() rows = make_fake_report_rows()
rows[0]["project"] = "A|B" rows[0]["project"] = "A|B"

View File

@@ -1,6 +1,7 @@
from redmine_reporter.report_builder import ( from redmine_reporter.report_builder import (
STATUS_TRANSLATION, STATUS_TRANSLATION,
build_grouped_report, build_grouped_report,
calculate_summary,
group_rows_by_project_and_version, group_rows_by_project_and_version,
) )
@@ -60,7 +61,61 @@ def test_build_grouped_report_grouping():
assert rows[0]["status_ru"] == "В работе" assert rows[0]["status_ru"] == "В работе"
assert rows[0]["time_text"] == "" assert rows[0]["time_text"] == ""
assert rows[0]["hours"] == 2.0
assert rows[1]["time_text"] == "1ч 30м" assert rows[1]["time_text"] == "1ч 30м"
assert rows[1]["hours"] == 1.5
# -- #22: Сводка по времени --
def test_calculate_summary_totals():
"""Сводка содержит общее время и разбивку по проектам/версиям."""
rows = [
{
"project": "Камеры",
"version": "v2.5.0",
"issue_id": 101,
"subject": "Фича A",
"status_ru": "В работе",
"time_text": "",
"hours": 2.0,
},
{
"project": "Камеры",
"version": "v2.5.0",
"issue_id": 102,
"subject": "Баг B",
"status_ru": "Решена",
"time_text": "1ч 30м",
"hours": 1.5,
},
{
"project": "ПО",
"version": "<N/A>",
"issue_id": 201,
"subject": "Доки",
"status_ru": "Ожидание",
"time_text": "",
"hours": 4.0,
},
]
summary = calculate_summary(rows)
assert summary["total"] == 7.5
assert summary["project:Камеры"] == 3.5
assert summary["project:ПО"] == 4.0
assert summary["version:Камеры::v2.5.0"] == 3.5
assert summary["version:ПО::<N/A>"] == 4.0
def test_calculate_summary_empty():
"""Сводка для пустого списка -- только total = 0."""
summary = calculate_summary([])
assert summary == {"total": 0.0}
# -- #19: Общая функция группировки --
def test_build_grouped_report_new_version_same_project(): def test_build_grouped_report_new_version_same_project():