From 4647986aeb4621913a8d302be454a840be7e1bf9 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhodawala <99460106+Abdeali099@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:55:51 +0530 Subject: [PATCH] fix!: Always `Bold` report header row (#34703) * fix: Always bold report header row * fix: update test cases * refactor: add option to bold filter rows in XLSX output * chore: minor changes * chore: rename index variable * revert: undo bold filters param use * refactor: remove duplication for building xlsx data * revert: add has_filters parameter to check for filter row bold * refactor: add type hints and docstrings for XLSX data handling functions --- frappe/desk/query_report.py | 48 +++++++++++++++---- .../auto_email_report/auto_email_report.py | 26 ++++++---- frappe/tests/test_query_report.py | 10 ++-- frappe/utils/xlsxutils.py | 48 ++++++++++++++----- 4 files changed, 97 insertions(+), 35 deletions(-) diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 3347dc9a25..00a387c3f6 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -5,6 +5,7 @@ import datetime import json import os from datetime import timedelta +from typing import Any import frappe import frappe.desk.reportview @@ -384,7 +385,7 @@ def _export_query(form_params, csv_params, populate_response=True): return format_fields(data) - xlsx_data, column_widths = build_xlsx_data( + xlsx_data, column_widths, header_index = build_xlsx_data( data, visible_idx, include_indentation, @@ -400,7 +401,13 @@ def _export_query(form_params, csv_params, populate_response=True): file_extension = "csv" elif file_format_type == "Excel": file_extension = "xlsx" - content = make_xlsx(xlsx_data, "Query Report", column_widths=column_widths).getvalue() + content = make_xlsx( + xlsx_data, + "Query Report", + column_widths=column_widths, + header_index=header_index, + has_filters=bool(include_filters), + ).getvalue() if include_filters: for value in (data.filters or {}).values(): @@ -440,13 +447,30 @@ def format_fields(data: frappe._dict) -> None: def build_xlsx_data( - data, - visible_idx, - include_indentation, - include_filters=False, - ignore_visible_idx=False, - include_hidden_columns=False, -): + data: frappe._dict, + visible_idx: list[int], + include_indentation: bool, + include_filters: bool = False, + ignore_visible_idx: bool = False, + include_hidden_columns: bool = False, +) -> tuple[list[list[Any]], list[int], int]: + """ + Build Excel data structure from report data with proper formatting. + + Args: + data: Report data containing columns, result, and filters + visible_idx: List of row indices that are visible in the report + include_indentation: Whether to include indentation for tree-like data + include_filters: Whether to include filter rows at the top of the Excel sheet + ignore_visible_idx: Whether to ignore the visible_idx parameter + include_hidden_columns: Whether to include columns marked as hidden + + Returns: + tuple: A tuple containing: + - result: List of rows for the Excel sheet + - column_widths: List of column widths for the Excel sheet + - header_index: Index of the header row in the result + """ EXCEL_TYPES = ( str, bool, @@ -468,6 +492,7 @@ def build_xlsx_data( result = [] column_widths = [] + header_index = 0 include_hidden_columns = cint(include_hidden_columns) include_indentation = cint(include_indentation) @@ -487,6 +512,9 @@ def build_xlsx_data( filter_data.append([]) result += filter_data + # header is after filters + 1 empty row + header_index = len(result) + column_data = [] for column in data.columns: if column.get("hidden") and not include_hidden_columns: @@ -525,7 +553,7 @@ def build_xlsx_data( result.append(row_data) - return result, column_widths + return result, column_widths, header_index def add_total_row(result, columns, meta=None, is_tree=False, parent_field=None): diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py index f92562abe5..d2440e6aeb 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -171,22 +171,28 @@ class AutoEmailReport(Document): columns = update_field_types(columns) return self.get_html_table(columns, data) - elif self.format == "XLSX": + elif self.format in ("XLSX", "CSV"): report_data = frappe._dict() report_data["columns"] = columns report_data["result"] = data - xlsx_data, column_widths = build_xlsx_data(report_data, [], 1, ignore_visible_idx=True) - xlsx_file = make_xlsx(xlsx_data, "Auto Email Report", column_widths=column_widths) - return xlsx_file.getvalue() + xlsx_data, column_widths, header_index = build_xlsx_data( + report_data, [], 1, ignore_visible_idx=True + ) - elif self.format == "CSV": - report_data = frappe._dict() - report_data["columns"] = columns - report_data["result"] = data + if self.format == "XLSX": + xlsx_file = make_xlsx( + xlsx_data, + "Auto Email Report", + column_widths=column_widths, + header_index=header_index, + has_filters=bool(self.filters), + ) - xlsx_data, column_widths = build_xlsx_data(report_data, [], 1, ignore_visible_idx=True) - return to_csv(xlsx_data) + return xlsx_file.getvalue() + + else: + return to_csv(xlsx_data) elif self.format == "PDF": columns, data = make_links(columns, data) diff --git a/frappe/tests/test_query_report.py b/frappe/tests/test_query_report.py index 2ad356c924..d6886dee8e 100644 --- a/frappe/tests/test_query_report.py +++ b/frappe/tests/test_query_report.py @@ -27,7 +27,7 @@ class TestQueryReport(IntegrationTestCase): visible_idx = [0, 2, 3] # Build the result - xlsx_data, column_widths = build_xlsx_data(data, visible_idx, include_indentation=0) + xlsx_data, column_widths, _ = build_xlsx_data(data, visible_idx, include_indentation=0) self.assertEqual(type(xlsx_data), list) self.assertEqual(len(xlsx_data), 4) # columns + data @@ -53,10 +53,12 @@ class TestQueryReport(IntegrationTestCase): visible_idx = [0, 2, 3] # Build the result - xlsx_data, _column_widths = build_xlsx_data( + xlsx_data, _column_widths, header_index = build_xlsx_data( data, visible_idx, include_indentation=False, include_filters=True ) + self.assertEqual(header_index, 3) # 2 filter rows + 1 empty row + # Check if unset filters are skipped | Rows - 2 filters + 1 empty + 1 column + 3 data self.assertEqual(len(xlsx_data), 7) @@ -80,9 +82,9 @@ class TestQueryReport(IntegrationTestCase): visible_idx = [0, 1] # Build the result - xlsx_data, column_widths = build_xlsx_data(data, visible_idx, include_indentation=0) + xlsx_data, column_widths, header_index = build_xlsx_data(data, visible_idx, include_indentation=0) # Export to excel - make_xlsx(xlsx_data, "Query Report", column_widths=column_widths) + make_xlsx(xlsx_data, "Query Report", column_widths=column_widths, header_index=header_index) for row in xlsx_data: # column_b should be 'str' even with composite cell value diff --git a/frappe/utils/xlsxutils.py b/frappe/utils/xlsxutils.py index 0dc01f09ab..bc379df2d7 100644 --- a/frappe/utils/xlsxutils.py +++ b/frappe/utils/xlsxutils.py @@ -3,6 +3,7 @@ import datetime import re from io import BytesIO +from typing import Any import openpyxl import xlrd @@ -31,7 +32,28 @@ def get_excel_date_format(): # return xlsx file object -def make_xlsx(data, sheet_name, wb=None, column_widths=None): +def make_xlsx( + data: list[list[Any]], + sheet_name: str, + wb: openpyxl.Workbook | None = None, + column_widths: list[int] | None = None, + header_index: int = 0, + has_filters: bool = False, +) -> BytesIO: + """ + Create an Excel file with the given data and formatting options. + + Args: + data: List of rows, where each row is a list of cell values + sheet_name: Name of the Excel sheet + wb: Existing workbook to add sheet to. If None, creates new workbook + column_widths: List of column widths in Excel units. If None, auto-sized + header_index: Row index (0-based) that should be formatted as header making it bold + has_filters: If True, applies bold formatting to the first column of filter rows + + Returns: + BytesIO: object containing the Excel file data + """ column_widths = column_widths or [] if wb is None: wb = openpyxl.Workbook(write_only=True) @@ -43,14 +65,15 @@ def make_xlsx(data, sheet_name, wb=None, column_widths=None): if column_width: ws.column_dimensions[get_column_letter(i + 1)].width = column_width - row1 = ws.row_dimensions[1] - row1.font = Font(name="Calibri", bold=True) - date_format, time_format = get_excel_date_format() + bold_font = Font(name="Calibri", bold=True) - for row in data: + for row_idx, row in enumerate(data): clean_row = [] - for item in row: + is_header_row = row_idx == header_index + is_filter_row = has_filters and row_idx < header_index + + for col_idx, item in enumerate(row): if isinstance(item, str) and (sheet_name not in ["Data Import Template", "Data Export"]): value = handle_html(item) else: @@ -60,16 +83,19 @@ def make_xlsx(data, sheet_name, wb=None, column_widths=None): # Remove illegal characters from the string value = ILLEGAL_CHARACTERS_RE.sub("", value) + cell = WriteOnlyCell(ws, value=value) + if isinstance(value, datetime.date | datetime.datetime): number_format = date_format if isinstance(value, datetime.datetime): number_format = f"{date_format} {time_format}" - - cell = WriteOnlyCell(ws, value=value) cell.number_format = number_format - clean_row.append(cell) - else: - clean_row.append(value) + + # Apply bold font for header row or first column of filter rows + if is_header_row or (is_filter_row and col_idx == 0): + cell.font = bold_font + + clean_row.append(cell) ws.append(clean_row)