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

@@ -170,6 +170,18 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
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("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):

View File

@@ -1,4 +1,5 @@
import io
import json
from typing import List
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.csv import CSVFormatter
from redmine_reporter.formatters.factory import get_formatter_by_extension
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.odt import ODTFormatter
from redmine_reporter.formatters.xlsx import XLSXFormatter
from redmine_reporter.types import ReportRow
@@ -223,6 +227,7 @@ TEXT_FORMATTER_FACTORIES = [
("table", lambda: TableFormatter()),
("compact", lambda: CompactFormatter()),
("csv", lambda: CSVFormatter()),
("json", lambda: JSONFormatter()),
("markdown", lambda: MarkdownFormatter()),
]
@@ -250,6 +255,10 @@ def test_text_formatter_contains_key_content(fake_rows, name, factory):
# В CSV issue_id и subject -- отдельные колонки
assert "101" in output
assert "Реализовать фичу X" in output
elif name == "json":
# В JSON issue_id -- число, subject -- отдельное поле
assert '"issue_id": 101' in output
assert "Реализовать фичу X" in output
else:
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]
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():
rows = make_fake_report_rows()
rows[0]["project"] = "A|B"

View File

@@ -1,6 +1,7 @@
from redmine_reporter.report_builder import (
STATUS_TRANSLATION,
build_grouped_report,
calculate_summary,
group_rows_by_project_and_version,
)
@@ -60,7 +61,61 @@ def test_build_grouped_report_grouping():
assert rows[0]["status_ru"] == "В работе"
assert rows[0]["time_text"] == ""
assert rows[0]["hours"] == 2.0
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():