diff --git a/README.md b/README.md index 89919b1..2bb1d30 100644 --- a/README.md +++ b/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 +``` + ## Разработка Установка зависимостей для разработки: diff --git a/pyproject.toml b/pyproject.toml index 6dfd4b5..af862ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 1f0eacd..4b8688a 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -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}", diff --git a/redmine_reporter/formatters/factory.py b/redmine_reporter/formatters/factory.py index beee23a..720bdcc 100644 --- a/redmine_reporter/formatters/factory.py +++ b/redmine_reporter/formatters/factory.py @@ -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, } diff --git a/redmine_reporter/formatters/json.py b/redmine_reporter/formatters/json.py new file mode 100644 index 0000000..c2bb353 --- /dev/null +++ b/redmine_reporter/formatters/json.py @@ -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) diff --git a/redmine_reporter/formatters/xlsx.py b/redmine_reporter/formatters/xlsx.py new file mode 100644 index 0000000..1a99132 --- /dev/null +++ b/redmine_reporter/formatters/xlsx.py @@ -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) diff --git a/redmine_reporter/report_builder.py b/redmine_reporter/report_builder.py index 1f6af6d..67b592b 100644 --- a/redmine_reporter/report_builder.py +++ b/redmine_reporter/report_builder.py @@ -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]]]: diff --git a/redmine_reporter/types.py b/redmine_reporter/types.py index e881eed..c60a4f5 100644 --- a/redmine_reporter/types.py +++ b/redmine_reporter/types.py @@ -12,3 +12,4 @@ class ReportRow(TypedDict): subject: str status_ru: str time_text: str + hours: float diff --git a/tests/test_cli.py b/tests/test_cli.py index 26ee8c6..cfa1fa0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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): diff --git a/tests/test_formatters.py b/tests/test_formatters.py index b1d19c7..f08ba77 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -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" diff --git a/tests/test_report_builder.py b/tests/test_report_builder.py index 4ea49a5..1694285 100644 --- a/tests/test_report_builder.py +++ b/tests/test_report_builder.py @@ -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": "", + "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:ПО::"] == 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():