Extract grouping logic into shared function (#19)

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
This commit is contained in:
Артём Кокос
2026-06-26 00:21:58 +07:00
parent dbc4cf960a
commit da069993b9
4 changed files with 138 additions and 26 deletions

View File

@@ -1,6 +1,7 @@
from html import escape 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 ..types import ReportRow
from .base import Formatter from .base import Formatter
@@ -12,16 +13,7 @@ class HTMLFormatter(Formatter):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
# Сгруппируем данные projects = group_rows_by_project_and_version(rows)
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)
lines = [ lines = [
'<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse; font-family: Arial, sans-serif;">', '<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse; font-family: Arial, sans-serif;">',

View File

@@ -1,11 +1,12 @@
from importlib import resources from importlib import resources
from typing import Dict, List from typing import List
from odf.opendocument import OpenDocument, load from odf.opendocument import OpenDocument, load
from odf.style import Style, TableCellProperties, TableColumnProperties from odf.style import Style, TableCellProperties, TableColumnProperties
from odf.table import Table, TableCell, TableColumn, TableRow from odf.table import Table, TableCell, TableColumn, TableRow
from odf.text import P from odf.text import P
from ..report_builder import group_rows_by_project_and_version
from ..types import ReportRow from ..types import ReportRow
from ..utils import get_month_name_from_range from ..utils import get_month_name_from_range
from .base import Formatter from .base import Formatter
@@ -69,18 +70,7 @@ class ODTFormatter(Formatter):
header_row.addElement(cell) header_row.addElement(cell)
table.addElement(header_row) table.addElement(header_row)
projects: Dict[str, Dict[str, List[ReportRow]]] = {} projects = group_rows_by_project_and_version(rows)
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)
# Данные с двухуровневой группировкой и объединением ячеек # Данные с двухуровневой группировкой и объединением ячеек
for project, versions in projects.items(): for project, versions in projects.items():

View File

@@ -1,4 +1,4 @@
from typing import List, Tuple, cast from typing import Dict, List, Tuple, cast
from redminelib.resources import Issue from redminelib.resources import Issue
@@ -71,3 +71,28 @@ def build_grouped_report(
prev_version = version prev_version = version
return rows 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

View File

@@ -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: class MockIssue:
@@ -117,3 +121,104 @@ def test_build_grouped_report_deterministic_order_within_group():
] ]
rows = build_grouped_report(issues) rows = build_grouped_report(issues)
assert [r["issue_id"] for r in rows] == [101, 102, 103] 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": "",
},
{
"project": "A",
"version": "v2",
"issue_id": 2,
"subject": "T2",
"status_ru": "S",
"time_text": "",
},
{
"project": "B",
"version": "v1",
"issue_id": 3,
"subject": "T3",
"status_ru": "S",
"time_text": "",
},
]
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": "",
},
{
"project": "A",
"version": "v1",
"issue_id": 2,
"subject": "T2",
"status_ru": "S",
"time_text": "",
},
{
"project": "A",
"version": "v1",
"issue_id": 3,
"subject": "T3",
"status_ru": "S",
"time_text": "",
},
]
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": "",
},
]
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"] == ""
def test_group_rows_empty():
"""Пустой список — пустой словарь."""
assert group_rows_by_project_and_version([]) == {}