diff --git a/api/src/services/export_service/export_writers.py b/api/src/services/export_service/export_writers.py index 7b7f1c8..196ef63 100644 --- a/api/src/services/export_service/export_writers.py +++ b/api/src/services/export_service/export_writers.py @@ -21,6 +21,22 @@ RowFormatResolver = Callable[[tuple, str, int], dict[str, Any] | None] ColumnFormatResolver = Callable[[str, int], dict[str, Any] | None] +def _hierarchical_rows(rows: list[tuple]): + """Use the same row-type hierarchy as the realtime table, skipping META.""" + levels = {"ROOT": 0, "GROUP": 1, "ITEM": 2, "SUB_ITEM": 3, "INPUT": 4} + stack: list[int] = [] + for row in rows: + item_type = row[0] if row else None + if item_type == "META": + continue + level = levels.get(item_type, 999) + while stack and stack[-1] >= level: + stack.pop() + yield row, len(stack) + if item_type != "INPUT": + stack.append(level) + + class AbstractWriter(abc.ABC): @staticmethod @@ -226,8 +242,10 @@ class DepthSheetWriter(AbstractWriter): def write(self, worksheet: Worksheet, rows: list[tuple]) -> None: column_widths: dict[int, int] = {} self._write_header(worksheet, column_widths=column_widths) + worksheet.sheet_properties.outlinePr.summaryBelow = False next_row = worksheet.max_row + 1 - for row in rows: + for row, outline_level in _hierarchical_rows(rows): + worksheet.row_dimensions[next_row].outlineLevel = min(outline_level, 7) if len(row) > 3 and isinstance(row[3], dict): if row[0] == 'META': continue @@ -489,6 +507,7 @@ class XlsxDepthSheetWriter(AbstractWriter): column_widths: dict[int, int] = {} header_rows = self._get_header_max_row(self.columns_dict) self._write_header(worksheet, column_widths=column_widths) + worksheet.outline_settings(visible=True, symbols_below=False) output_row = header_rows column_formats = None if self.column_format_resolver: @@ -499,7 +518,8 @@ class XlsxDepthSheetWriter(AbstractWriter): for column, column_key in enumerate(self.columns) ] - for row in rows: + for row, outline_level in _hierarchical_rows(rows): + worksheet.set_row(output_row, None, None, {"level": min(outline_level, 7)}) if len(row) > 3 and isinstance(row[3], dict): if row[0] == "META": continue diff --git a/api/tests/unit/test_export_writers.py b/api/tests/unit/test_export_writers.py index eb8e388..aa00885 100644 --- a/api/tests/unit/test_export_writers.py +++ b/api/tests/unit/test_export_writers.py @@ -1,13 +1,54 @@ from unittest.mock import AsyncMock +from io import BytesIO -from openpyxl import Workbook +import pytest +from openpyxl import Workbook, load_workbook +from xlsxwriter import Workbook as XlsxWorkbook from src.services.export_service import ExportService from src.services.export_service.export_mappings import ( FORM1_FLAT_COLUMNS, SMETA_COLUMNS, ) -from src.services.export_service.export_writers import TabularSheetWriter +from src.services.export_service.export_writers import ( + DepthSheetWriter, + TabularSheetWriter, + XlsxDepthSheetWriter, +) + + +@pytest.mark.parametrize("backend", ["openpyxl", "xlsxwriter", "xlsxwriter_columns"]) +def test_depth_writer_preserves_collapsible_row_hierarchy(backend): + stream = BytesIO() + workbook = Workbook() if backend == "openpyxl" else XlsxWorkbook(stream) + worksheet = workbook.active if backend == "openpyxl" else workbook.add_worksheet() + options = {} + if backend == "xlsxwriter_columns": + options["column_format_resolver"] = lambda key, column: {"bold": True} + writer_class = DepthSheetWriter if backend == "openpyxl" else XlsxDepthSheetWriter + if backend != "openpyxl": + options["workbook"] = workbook + writer = writer_class( + columns_dict={"header": {"name": "Header", "key": "", "children": { + "name": {"name": "Name"}, + }}}, + row_normalizer=lambda row: row, + value_serializer=lambda value: value, + **options, + ) + # Missing GROUP/SUB_ITEM levels and misleading numeric depths match frontend cases. + types = ["ROOT", "ITEM", "INPUT", "META", "INPUT", "ITEM", "SUB_ITEM", "INPUT", "ROOT"] + writer.write(worksheet, [(kind, 42, index, {"name": str(index)}) for index, kind in enumerate(types)]) + if backend == "openpyxl": + workbook.save(stream) + else: + workbook.close() + stream.seek(0) + sheet = load_workbook(stream).active + assert sheet.sheet_properties.outlinePr.summaryBelow is False + assert [sheet.row_dimensions[index].outlineLevel for index in range(3, 11)] == [0, 1, 2, 2, 1, 2, 3, 0] + assert [sheet.cell(index, 1).value for index in range(3, 11)] == ["0", "1", "2", "4", "5", "6", "7", "8"] + assert all(not sheet.row_dimensions[index].hidden for index in range(3, 11)) def test_tabular_sheet_writer_writes_headers_and_rows():