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
This commit is contained in:
Abdeali Chharchhodawala 2025-12-11 15:55:51 +05:30 committed by GitHub
parent a25db08e25
commit 4647986aeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 97 additions and 35 deletions

View file

@ -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):

View file

@ -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)

View file

@ -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

View file

@ -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)