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:
23
README.md
23
README.md
@@ -14,11 +14,13 @@ CLI-инструмент для генерации отчётов по зада
|
||||
- Группировка задач по проекту и версии.
|
||||
- Перевод статусов задач на русский язык.
|
||||
- Вывод в консоль в табличном или компактном виде.
|
||||
- Экспорт в ODT, CSV, Markdown и HTML.
|
||||
- Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
|
||||
- Сводка по затраченному времени (итоги и разбивка по проектам/версиям).
|
||||
- Автоматическое определение месяца ODT-отчёта по конечной дате периода.
|
||||
- Настройка периода отчёта по умолчанию через `.env` (или автоматически — текущий месяц).
|
||||
- Переопределение URL и API-ключа через CLI.
|
||||
- Загрузка альтернативного `.env` через `--config`.
|
||||
- Загрузка альтернативного `.env` через `--config`.
|
||||
|
||||
## Установка
|
||||
|
||||
@@ -140,8 +142,21 @@ redmine-reporter --output report.odt
|
||||
redmine-reporter --output report.csv
|
||||
redmine-reporter --output report.md
|
||||
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-отчёт:
|
||||
|
||||
- файл сохраняется в UTF-8 с BOM (`utf-8-sig`) для корректного отображения кириллицы в Microsoft Excel;
|
||||
@@ -164,6 +179,12 @@ ODT-отчёт:
|
||||
redmine-reporter --no-time
|
||||
```
|
||||
|
||||
Сводка по времени (итоги и разбивка по проектам):
|
||||
|
||||
```bash
|
||||
redmine-reporter --summary
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
Установка зависимостей для разработки:
|
||||
|
||||
@@ -23,6 +23,7 @@ dependencies = [
|
||||
"tabulate>=0.9.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"odfpy>=1.4.0",
|
||||
"openpyxl>=3.1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -10,7 +10,7 @@ from . import __version__
|
||||
from .client import fetch_issues_with_spent_time
|
||||
from .config import Config
|
||||
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]:
|
||||
@@ -52,7 +52,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--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(
|
||||
"--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__}",
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
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:
|
||||
output_ext = os.path.splitext(args.output)[1].lower()
|
||||
|
||||
if not output_ext:
|
||||
print(
|
||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md или .html",
|
||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
@@ -137,7 +150,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
known_exts = ", ".join([".odt", ".csv", ".md", ".html"])
|
||||
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
|
||||
print(
|
||||
f"❌ Неизвестный формат файла: {output_ext!r}. "
|
||||
f"Поддерживаются: {known_exts}",
|
||||
|
||||
@@ -4,15 +4,19 @@ from .base import Formatter
|
||||
from .console import CompactFormatter, TableFormatter
|
||||
from .csv import CSVFormatter
|
||||
from .html import HTMLFormatter
|
||||
from .json import JSONFormatter
|
||||
from .markdown import MarkdownFormatter
|
||||
from .xlsx import XLSXFormatter
|
||||
|
||||
# Словарь для сопоставления расширений файлов с классами форматтеров.
|
||||
# ODT намеренно отсутствует — его импорт отложен (ленивый), так как odfpy
|
||||
# может быть не установлен. См. get_formatter_by_extension.
|
||||
# ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
|
||||
# odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
|
||||
FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
||||
".csv": CSVFormatter,
|
||||
".json": JSONFormatter,
|
||||
".md": MarkdownFormatter,
|
||||
".html": HTMLFormatter,
|
||||
".xlsx": XLSXFormatter,
|
||||
}
|
||||
|
||||
|
||||
|
||||
31
redmine_reporter/formatters/json.py
Normal file
31
redmine_reporter/formatters/json.py
Normal 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)
|
||||
42
redmine_reporter/formatters/xlsx.py
Normal file
42
redmine_reporter/formatters/xlsx.py
Normal 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)
|
||||
@@ -63,6 +63,7 @@ def build_grouped_report(
|
||||
"subject": issue.subject,
|
||||
"status_ru": status_ru,
|
||||
"time_text": time_text,
|
||||
"hours": round(hours, 2),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -73,6 +74,26 @@ def build_grouped_report(
|
||||
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(
|
||||
rows: List[ReportRow],
|
||||
) -> Dict[str, Dict[str, List[ReportRow]]]:
|
||||
|
||||
@@ -12,3 +12,4 @@ class ReportRow(TypedDict):
|
||||
subject: str
|
||||
status_ru: str
|
||||
time_text: str
|
||||
hours: float
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] == "2ч"
|
||||
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": "2ч",
|
||||
"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": "4ч",
|
||||
"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():
|
||||
|
||||
Reference in New Issue
Block a user