From da069993b94cbc102106a4e7f445fad0a150ce0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D1=91=D0=BC=20=D0=9A=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D1=81?= Date: Fri, 26 Jun 2026 00:21:58 +0700 Subject: [PATCH] Extract grouping logic into shared function (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add group_rows_by_project_and_version() to report_builder.py and replace the duplicated inline grouping in both html.py and odt.py with a single call to the shared function. This ensures consistent grouping behavior across formatters and removes a maintenance burden — any change to grouping logic now happens in one place. Closes #19 --- redmine_reporter/formatters/html.py | 14 +--- redmine_reporter/formatters/odt.py | 16 +---- redmine_reporter/report_builder.py | 27 ++++++- tests/test_report_builder.py | 107 +++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 26 deletions(-) diff --git a/redmine_reporter/formatters/html.py b/redmine_reporter/formatters/html.py index f2459c7..2fd9bdd 100644 --- a/redmine_reporter/formatters/html.py +++ b/redmine_reporter/formatters/html.py @@ -1,6 +1,7 @@ from html import escape -from typing import Dict, List +from typing import List +from ..report_builder import group_rows_by_project_and_version from ..types import ReportRow from .base import Formatter @@ -12,16 +13,7 @@ class HTMLFormatter(Formatter): super().__init__() def format(self, rows: List[ReportRow]) -> str: - # Сгруппируем данные - projects: Dict[str, Dict[str, List[ReportRow]]] = {} - for r in rows: - proj = r["project"] - ver = r["version"] - if proj not in projects: - projects[proj] = {} - if ver not in projects[proj]: - projects[proj][ver] = [] - projects[proj][ver].append(r) + projects = group_rows_by_project_and_version(rows) lines = [ '', diff --git a/redmine_reporter/formatters/odt.py b/redmine_reporter/formatters/odt.py index 0d71b2a..ac47947 100644 --- a/redmine_reporter/formatters/odt.py +++ b/redmine_reporter/formatters/odt.py @@ -1,11 +1,12 @@ from importlib import resources -from typing import Dict, List +from typing import List from odf.opendocument import OpenDocument, load from odf.style import Style, TableCellProperties, TableColumnProperties from odf.table import Table, TableCell, TableColumn, TableRow from odf.text import P +from ..report_builder import group_rows_by_project_and_version from ..types import ReportRow from ..utils import get_month_name_from_range from .base import Formatter @@ -69,18 +70,7 @@ class ODTFormatter(Formatter): header_row.addElement(cell) table.addElement(header_row) - projects: Dict[str, Dict[str, List[ReportRow]]] = {} - for r in rows: - project = r["project"] - version = r["version"] - - if project not in projects: - projects[project] = {} - - if version not in projects[project]: - projects[project][version] = [] - - projects[project][version].append(r) + projects = group_rows_by_project_and_version(rows) # Данные с двухуровневой группировкой и объединением ячеек for project, versions in projects.items(): diff --git a/redmine_reporter/report_builder.py b/redmine_reporter/report_builder.py index 5c0c75e..1f6af6d 100644 --- a/redmine_reporter/report_builder.py +++ b/redmine_reporter/report_builder.py @@ -1,4 +1,4 @@ -from typing import List, Tuple, cast +from typing import Dict, List, Tuple, cast from redminelib.resources import Issue @@ -71,3 +71,28 @@ def build_grouped_report( prev_version = version return rows + + +def group_rows_by_project_and_version( + rows: List[ReportRow], +) -> Dict[str, Dict[str, List[ReportRow]]]: + """ + Группирует плоский список строк отчёта в иерархию project → version → [rows]. + + Предполагается, что rows уже отсортирован (build_grouped_report это гарантирует). + Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+). + Используется форматтерами HTML и ODT для объединения ячеек. + """ + projects: Dict[str, Dict[str, List[ReportRow]]] = {} + for r in rows: + project = r["project"] + version = r["version"] + + if project not in projects: + projects[project] = {} + if version not in projects[project]: + projects[project][version] = [] + + projects[project][version].append(r) + + return projects diff --git a/tests/test_report_builder.py b/tests/test_report_builder.py index fa71904..4ea49a5 100644 --- a/tests/test_report_builder.py +++ b/tests/test_report_builder.py @@ -1,4 +1,8 @@ -from redmine_reporter.report_builder import STATUS_TRANSLATION, build_grouped_report +from redmine_reporter.report_builder import ( + STATUS_TRANSLATION, + build_grouped_report, + group_rows_by_project_and_version, +) class MockIssue: @@ -117,3 +121,104 @@ def test_build_grouped_report_deterministic_order_within_group(): ] rows = build_grouped_report(issues) assert [r["issue_id"] for r in rows] == [101, 102, 103] + + +# -- #19: Общая функция группировки -- + + +def test_group_rows_basic(): + """Два проекта, в одном две версии — структура корректна.""" + rows = [ + { + "project": "A", + "version": "v1", + "issue_id": 1, + "subject": "T1", + "status_ru": "S", + "time_text": "1ч", + }, + { + "project": "A", + "version": "v2", + "issue_id": 2, + "subject": "T2", + "status_ru": "S", + "time_text": "2ч", + }, + { + "project": "B", + "version": "v1", + "issue_id": 3, + "subject": "T3", + "status_ru": "S", + "time_text": "3ч", + }, + ] + grouped = group_rows_by_project_and_version(rows) + + assert list(grouped.keys()) == ["A", "B"] + assert list(grouped["A"].keys()) == ["v1", "v2"] + assert list(grouped["B"].keys()) == ["v1"] + assert len(grouped["A"]["v1"]) == 1 + assert len(grouped["A"]["v2"]) == 1 + assert len(grouped["B"]["v1"]) == 1 + + +def test_group_rows_multiple_tasks_per_version(): + """Несколько задач в одной версии — все попадают в список.""" + rows = [ + { + "project": "A", + "version": "v1", + "issue_id": 1, + "subject": "T1", + "status_ru": "S", + "time_text": "1ч", + }, + { + "project": "A", + "version": "v1", + "issue_id": 2, + "subject": "T2", + "status_ru": "S", + "time_text": "2ч", + }, + { + "project": "A", + "version": "v1", + "issue_id": 3, + "subject": "T3", + "status_ru": "S", + "time_text": "3ч", + }, + ] + grouped = group_rows_by_project_and_version(rows) + + assert len(grouped["A"]["v1"]) == 3 + assert [r["issue_id"] for r in grouped["A"]["v1"]] == [1, 2, 3] + + +def test_group_rows_preserves_row_data(): + """Строки в группировке — те же объекты, без потери данных.""" + rows = [ + { + "project": "P", + "version": "v1", + "issue_id": 42, + "subject": "Task", + "status_ru": "Готово", + "time_text": "5ч", + }, + ] + grouped = group_rows_by_project_and_version(rows) + + row = grouped["P"]["v1"][0] + assert row["issue_id"] == 42 + assert row["subject"] == "Task" + assert row["status_ru"] == "Готово" + assert row["time_text"] == "5ч" + + +def test_group_rows_empty(): + """Пустой список — пустой словарь.""" + assert group_rows_by_project_and_version([]) == {}