feat(reports): add uncategorized dual-share exports

This commit is contained in:
2026-05-21 19:10:33 +03:30
parent e234eac26d
commit 8d2f876c82
5 changed files with 838 additions and 319 deletions

View File

@@ -173,7 +173,13 @@ def _append_user_summary_block(worksheet, *, locale: ExportLocale, user_summary:
[locale.t("working_hours"), locale.format_duration(user_summary["billable_duration"], ascii_digits=True)],
),
_excel_pair_row(
[locale.t("non_working_hours"), locale.format_duration(user_summary["non_billable_duration"], ascii_digits=True)],
[
locale.t("non_working_hours"),
locale.format_duration(
user_summary["non_billable_duration"],
ascii_digits=True,
),
],
),
_excel_pair_row([locale.t("income"), _money_label_excel(locale, user_summary["income_totals"])]),
):
@@ -184,14 +190,49 @@ def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict,
row_id = str(row_data.get("id")) if row_data.get("id") is not None else None
row_name = row_data.get("name")
for row in rows:
value = f"{locale.format_amount(row['percentage'], ascii_digits=ascii_digits)}%"
value = _percentage_value(locale, row["percentage"], ascii_digits=ascii_digits)
if row_id is not None and str(row["id"]) == row_id:
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value
return value
if row_name and row["name"] == row_name:
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value
return value
return "-"
def _percentage_value(locale: ExportLocale, percentage: str, *, ascii_digits: bool = False) -> str:
value = f"{locale.format_amount(percentage, ascii_digits=ascii_digits)}%"
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value
def _summary_breakdown_rows(
locale: ExportLocale,
hour_rows: list[dict],
income_rows: list[dict],
) -> list[list[str]]:
if not hour_rows:
return []
return [
[
row["name"],
_percentage_value(locale, row["percentage"], ascii_digits=True),
_percentage_display(locale, income_rows, row, ascii_digits=True),
]
for row in hour_rows
]
def _summary_period_label(locale: ExportLocale, rate_periods: list[dict], *, ascii_digits: bool = False) -> str:
if not rate_periods:
return locale.t("none")
first_row = rate_periods[0]
last_row = rate_periods[-1]
return (
f"{locale.format_date(first_row['from_date'], ascii_digits=ascii_digits)} - "
f"{locale.format_date(last_row['to_date'], ascii_digits=ascii_digits)}"
)
def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None:
worksheet.append([])
_append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=3)
@@ -252,8 +293,16 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
worksheet.append(_excel_pair_row([locale.t("report_title"), scope["workspace"]["name"]]))
worksheet.append(_excel_pair_row([locale.t("workspace"), scope["workspace"]["name"]]))
worksheet.append(_excel_pair_row([locale.t("period"), locale.period_label(scope["period"])]))
worksheet.append(_excel_pair_row([locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)]))
worksheet.append(_excel_pair_row([locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)]))
worksheet.append(
_excel_pair_row(
[locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)],
)
)
worksheet.append(
_excel_pair_row(
[locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)],
)
)
worksheet.append(
_excel_pair_row(
[
@@ -266,16 +315,48 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
_excel_pair_row(
[
locale.t("mobile"),
locale.format_number(scope["user"]["mobile"], ascii_digits=True) if scope.get("user") and scope["user"].get("mobile") else "-",
(
locale.format_number(
scope["user"]["mobile"],
ascii_digits=True,
)
if scope.get("user") and scope["user"].get("mobile")
else "-"
),
],
)
)
worksheet.append(
_excel_pair_row(
[
locale.t("generated_at"),
locale.format_date(datetime.now().date(), ascii_digits=True),
],
)
)
worksheet.append(_excel_pair_row([locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)]))
worksheet.append([])
_append_merged_heading(worksheet, locale=locale, title=locale.t("summary"), span=2)
worksheet.append(_excel_pair_row([locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)]))
worksheet.append(_excel_pair_row([locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)]))
worksheet.append(_excel_pair_row([locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)]))
worksheet.append(
_excel_pair_row(
[locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)],
)
)
worksheet.append(
_excel_pair_row(
[locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)],
)
)
worksheet.append(
_excel_pair_row(
[
locale.t("non_billable_hours"),
locale.format_duration(
summary["non_billable_duration"],
ascii_digits=True,
),
],
)
)
worksheet.append(_excel_pair_row([locale.t("income"), _money_label_excel(locale, summary["income_totals"])]))
for row_index in range(1, worksheet.max_row + 1):
@@ -284,7 +365,12 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) ->
if row_index in {1, 10}:
_apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
if second_cell.value:
_apply_cell_style(second_cell, bold=row_index == 1, fill=HEADER_FILL if row_index == 1 else None, rtl=locale.is_rtl)
_apply_cell_style(
second_cell,
bold=row_index == 1,
fill=HEADER_FILL if row_index == 1 else None,
rtl=locale.is_rtl,
)
elif first_cell.value:
_apply_cell_style(first_cell, bold=False, fill=None, rtl=locale.is_rtl)
if second_cell.value:
@@ -353,25 +439,26 @@ def _append_breakdown_table(
locale: ExportLocale,
title_key: str,
rows: list[dict],
percentages: list[dict] | None = None,
hour_percentages: list[dict] | None = None,
income_percentages: list[dict] | None = None,
) -> None:
worksheet.append([])
_append_merged_heading(
worksheet,
locale=locale,
title=locale.t(title_key),
span=6 if percentages is not None else 5,
span=7 if hour_percentages is not None else 5,
)
header_row = worksheet.max_row + 1
headers = [
locale.t("name"),
locale.t("billable_hours"),
*( [locale.t("hour_percentage")] if hour_percentages is not None else [] ),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("income"),
*( [locale.t("income_percentage")] if hour_percentages is not None else [] ),
]
if percentages is not None:
headers.append(locale.t("percentage"))
worksheet.append(_excel_table_row(headers))
for cell in worksheet[header_row]:
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
@@ -385,15 +472,21 @@ def _append_breakdown_table(
values = [
row["name"],
locale.format_duration(row["billable_duration"], ascii_digits=True),
*(
[_percentage_display(locale, hour_percentages or [], row, ascii_digits=True)]
if hour_percentages is not None
else []
),
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
locale.format_duration(row["total_duration"], ascii_digits=True),
_money_label_excel(locale, row["income_totals"]),
*(
[_percentage_display(locale, income_percentages or [], row, ascii_digits=True)]
if hour_percentages is not None
else []
),
]
if percentages is not None:
values.append(_percentage_display(locale, percentages, row, ascii_digits=True))
worksheet.append(
_excel_table_row(values)
)
worksheet.append(_excel_table_row(values))
for cell in worksheet[worksheet.max_row]:
_apply_cell_style(cell, rtl=locale.is_rtl)
@@ -415,21 +508,24 @@ def _append_user_details_block_excel(
locale=locale,
title_key="clients",
rows=report_data["clients"],
percentages=user_summary["client_percentages"],
hour_percentages=user_summary["client_percentages"],
income_percentages=user_summary["client_income_percentages"],
)
_append_breakdown_table(
worksheet,
locale=locale,
title_key="projects",
rows=report_data["projects"],
percentages=user_summary["project_percentages"],
hour_percentages=user_summary["project_percentages"],
income_percentages=user_summary["project_income_percentages"],
)
_append_breakdown_table(
worksheet,
locale=locale,
title_key="tags",
rows=report_data["tags"],
percentages=user_summary["tag_percentages"],
hour_percentages=user_summary["tag_percentages"],
income_percentages=user_summary["tag_income_percentages"],
)
@@ -469,22 +565,28 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
rate_rows = [
[
_rate_period_label(locale, row, ascii_digits=True),
f"{locale.format_date(row['from_date'], ascii_digits=True)} - {locale.format_date(row['to_date'], ascii_digits=True)}",
(
f"{locale.format_date(row['from_date'], ascii_digits=True)} - "
f"{locale.format_date(row['to_date'], ascii_digits=True)}"
),
]
for row in (summary.get("rate_periods") or [])
]
client_rows = [
[row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"]
for row in (summary.get("client_percentages") or [])
]
project_rows = [
[row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"]
for row in (summary.get("project_percentages") or [])
]
tag_rows = [
[row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"]
for row in (summary.get("tag_percentages") or [])
]
client_rows = _summary_breakdown_rows(
locale,
summary.get("client_percentages") or [],
summary.get("client_income_percentages") or [],
)
project_rows = _summary_breakdown_rows(
locale,
summary.get("project_percentages") or [],
summary.get("project_income_percentages") or [],
)
tag_rows = _summary_breakdown_rows(
locale,
summary.get("tag_percentages") or [],
summary.get("tag_income_percentages") or [],
)
span = max(len(rate_rows), len(client_rows), len(project_rows), len(tag_rows), 1)
rows: list[list[str | None]] = []
for index in range(span):
@@ -494,11 +596,11 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
locale.format_number(summary["user"]["mobile"], ascii_digits=True) if index == 0 else None,
locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None,
locale.format_duration(summary["non_billable_duration"], ascii_digits=True) if index == 0 else None,
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
*(rate_rows[index] if index < len(rate_rows) else [None, None]),
*(client_rows[index] if index < len(client_rows) else [None, None]),
*(project_rows[index] if index < len(project_rows) else [None, None]),
*(tag_rows[index] if index < len(tag_rows) else [None, None]),
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
*(client_rows[index] if index < len(client_rows) else [None, None, None]),
*(project_rows[index] if index < len(project_rows) else [None, None, None]),
*(tag_rows[index] if index < len(tag_rows) else [None, None, None]),
],
)
return span, rows
@@ -560,7 +662,7 @@ def _render_all_users_overall_excel_sheet(
worksheet,
row=15,
start_col=1,
end_col=13,
end_col=16,
value=locale.t("users_summary_sheet"),
rtl=locale.is_rtl,
)
@@ -569,15 +671,18 @@ def _render_all_users_overall_excel_sheet(
locale.t("mobile"),
locale.t("working_hours"),
locale.t("non_working_hours"),
locale.t("income"),
locale.t("hourly_rate"),
locale.t("period"),
locale.t("income"),
locale.t("clients"),
locale.t("percentage"),
locale.t("hour_percentage"),
locale.t("income_percentage"),
locale.t("projects"),
locale.t("percentage"),
locale.t("hour_percentage"),
locale.t("income_percentage"),
locale.t("tags"),
locale.t("percentage"),
locale.t("hour_percentage"),
locale.t("income_percentage"),
]
_write_table_row(
worksheet,
@@ -599,33 +704,58 @@ def _render_all_users_overall_excel_sheet(
values=values,
rtl=locale.is_rtl,
)
for column in range(1, 6):
for column in (1, 2, 3, 4, 7):
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column)
rate_rows = user_summary.get("rate_periods") or []
client_rows = user_summary.get("client_percentages") or []
project_rows = user_summary.get("project_percentages") or []
tag_rows = user_summary.get("tag_percentages") or []
if len(rate_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=6, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=7, value_present=True)
if len(client_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=8, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True)
if len(project_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True)
if len(project_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True)
if len(tag_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True)
if len(tag_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=14, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=15, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=16, value_present=True)
current_row += span
current_row += 2
for title_key, rows, percentages in (
("clients", report_data["clients"], report_data.get("client_percentages")),
("projects", report_data["projects"], report_data.get("project_percentages")),
("tags", report_data["tags"], report_data.get("tag_percentages")),
for title_key, rows, hour_percentages, income_percentages in (
(
"clients",
report_data["clients"],
report_data.get("client_percentages"),
report_data.get("client_income_percentages"),
),
(
"projects",
report_data["projects"],
report_data.get("project_percentages"),
report_data.get("project_income_percentages"),
),
(
"tags",
report_data["tags"],
report_data.get("tag_percentages"),
report_data.get("tag_income_percentages"),
),
):
_merge_and_style(worksheet, row=current_row, start_col=1, end_col=6, value=locale.t(title_key), rtl=locale.is_rtl)
_merge_and_style(
worksheet,
row=current_row,
start_col=1,
end_col=7,
value=locale.t(title_key),
rtl=locale.is_rtl,
)
current_row += 1
_write_table_row(
worksheet,
@@ -634,10 +764,11 @@ def _render_all_users_overall_excel_sheet(
values=[
locale.t("name"),
locale.t("billable_hours"),
locale.t("hour_percentage"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("income"),
locale.t("percentage"),
locale.t("income_percentage"),
],
rtl=locale.is_rtl,
bold=True,
@@ -653,10 +784,11 @@ def _render_all_users_overall_excel_sheet(
values=[
row["name"],
locale.format_duration(row["billable_duration"], ascii_digits=True),
_percentage_display(locale, hour_percentages or [], row, ascii_digits=True),
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
locale.format_duration(row["total_duration"], ascii_digits=True),
_money_label_excel(locale, row["income_totals"]),
_percentage_display(locale, percentages or [], row, ascii_digits=True),
_percentage_display(locale, income_percentages or [], row, ascii_digits=True),
],
rtl=locale.is_rtl,
)
@@ -666,7 +798,7 @@ def _render_all_users_overall_excel_sheet(
worksheet,
row=current_row,
start_col=1,
values=[locale.t("no_data"), None, None, None, None, None],
values=[locale.t("no_data"), None, None, None, None, None, None],
rtl=locale.is_rtl,
)
current_row += 1
@@ -677,15 +809,18 @@ def _render_all_users_overall_excel_sheet(
"B": 19.86,
"C": 18.0,
"D": 17.0,
"E": 24.0,
"F": 17.57,
"G": 32.0,
"H": 30.0,
"E": 18.0,
"F": 26.0,
"G": 24.0,
"H": 28.0,
"I": 14.0,
"J": 32.86,
"K": 12.0,
"L": 22.0,
"M": 12.0,
"J": 16.0,
"K": 28.0,
"L": 14.0,
"M": 16.0,
"N": 24.0,
"O": 14.0,
"P": 16.0,
}
for column, width in overall_widths.items():
worksheet.column_dimensions[column].width = width
@@ -715,21 +850,48 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
locale=locale,
title_key="clients",
rows=report_data["clients"],
percentages=user_summary["client_percentages"] if user_summary else None,
hour_percentages=(
user_summary["client_percentages"]
if user_summary
else report_data.get("client_percentages")
),
income_percentages=(
user_summary["client_income_percentages"]
if user_summary
else report_data.get("client_income_percentages")
),
)
_append_breakdown_table(
worksheet,
locale=locale,
title_key="projects",
rows=report_data["projects"],
percentages=user_summary["project_percentages"] if user_summary else None,
hour_percentages=(
user_summary["project_percentages"]
if user_summary
else report_data.get("project_percentages")
),
income_percentages=(
user_summary["project_income_percentages"]
if user_summary
else report_data.get("project_income_percentages")
),
)
_append_breakdown_table(
worksheet,
locale=locale,
title_key="tags",
rows=report_data["tags"],
percentages=user_summary["tag_percentages"] if user_summary else None,
hour_percentages=(
user_summary["tag_percentages"]
if user_summary
else report_data.get("tag_percentages")
),
income_percentages=(
user_summary["tag_income_percentages"]
if user_summary
else report_data.get("tag_income_percentages")
),
)
_autosize_columns(worksheet)
@@ -764,7 +926,14 @@ def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_repo
_render_all_users_overall_excel_sheet(overall_sheet, locale=locale, report_data=report_data)
used_titles.add(overall_sheet.title)
for user_report in per_user_reports:
user_title = safe_sheet_title(user_label(user_report["scope"].get("user"), locale, ascii_digits=True), used_titles)
user_title = safe_sheet_title(
user_label(
user_report["scope"].get("user"),
locale,
ascii_digits=True,
),
used_titles,
)
worksheet = workbook.create_sheet(title=user_title)
_render_excel_sheet(worksheet, locale=locale, report_data=user_report)
used_titles.add(user_title)
@@ -924,61 +1093,77 @@ def _append_pdf_report_sections(
for title_key, rows, is_daily in sections:
story.append(_paragraph(locale.t(title_key), section_style, locale))
story.append(Spacer(1, 2 * mm))
header_values = [
locale.t("date") if is_daily else locale.t("name"),
locale.t("billable_hours"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
*([locale.t("hourly_rate")] if is_daily else []),
locale.t("income"),
]
percentage_rows = None
header_values = (
[
locale.t("date"),
locale.t("billable_hours"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("hourly_rate"),
locale.t("income"),
]
if is_daily
else [
locale.t("name"),
locale.t("billable_hours"),
locale.t("hour_percentage"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("income"),
locale.t("income_percentage"),
]
)
hour_percentage_rows = None
income_percentage_rows = None
if user_summary and not is_daily:
percentage_rows = user_summary[f"{title_key[:-1]}_percentages"] if title_key != "clients" else user_summary["client_percentages"]
header_values.append(locale.t("percentage"))
prefix = title_key[:-1] if title_key != "clients" else "client"
hour_percentage_rows = user_summary[f"{prefix}_percentages"]
income_percentage_rows = user_summary[f"{prefix}_income_percentages"]
header = _rtl_row(locale, header_values)
body_rows = _report_table_rows(locale, rows, is_daily=is_daily)
if percentage_rows is not None:
if hour_percentage_rows is not None:
body_rows = [
_rtl_row(
locale,
[
row["name"],
locale.format_duration(row["billable_duration"]),
_percentage_display(locale, hour_percentage_rows, row),
locale.format_duration(row["non_billable_duration"]),
locale.format_duration(row["total_duration"]),
_money_label(locale, row["income_totals"]),
_percentage_display(locale, percentage_rows, row),
_percentage_display(locale, income_percentage_rows or [], row),
],
)
for row in rows
] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", ""])]
] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", "", ""])]
table = _styled_table(
[header, *body_rows],
locale=locale,
column_widths=(
[
doc_width * 0.21,
doc_width * 0.13,
doc_width * 0.20,
doc_width * 0.12,
doc_width * 0.15,
doc_width * 0.13,
doc_width * 0.16,
doc_width * 0.22,
doc_width * 0.24,
]
if is_daily
else [
*(
[
doc_width * 0.24,
doc_width * 0.13,
doc_width * 0.15,
doc_width * 0.20,
doc_width * 0.11,
doc_width * 0.11,
doc_width * 0.12,
doc_width * 0.2,
doc_width * 0.16,
doc_width * 0.12,
doc_width * 0.19,
doc_width * 0.15,
]
if percentage_rows is not None
if hour_percentage_rows is not None
else [
doc_width * 0.26,
doc_width * 0.13,
doc_width * 0.15,
doc_width * 0.17,
doc_width * 0.14,
@@ -1063,7 +1248,14 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
[locale.t("from_date"), locale.format_date(scope["from_date"])],
[locale.t("to_date"), locale.format_date(scope["to_date"])],
[locale.t("user"), scope["user"]["name"] if scope.get("user") else locale.t("all_users")],
[locale.t("mobile"), locale.format_number(scope["user"]["mobile"]) if scope.get("user") and scope["user"].get("mobile") else "-"],
[
locale.t("mobile"),
(
locale.format_number(scope["user"]["mobile"])
if scope.get("user") and scope["user"].get("mobile")
else "-"
),
],
[locale.t("generated_at"), locale.format_date(datetime.now().date())],
]
if locale.is_rtl:
@@ -1122,6 +1314,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
locale.t("mobile"),
locale.t("working_hours"),
locale.t("non_working_hours"),
locale.t("hourly_rate"),
locale.t("period"),
locale.t("income"),
],
)
@@ -1133,6 +1327,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
locale.format_number(summary["user"]["mobile"]),
locale.format_duration(summary["billable_duration"]),
locale.format_duration(summary["non_billable_duration"]),
_rates_label(locale, summary.get("hourly_rates") or []),
_summary_period_label(locale, summary.get("rate_periods") or []),
_money_label(locale, summary["income_totals"]),
],
)
@@ -1143,11 +1339,13 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
[user_summary_header, *user_summary_rows],
locale=locale,
column_widths=[
doc.width * 0.24,
doc.width * 0.18,
doc.width * 0.18,
doc.width * 0.18,
doc.width * 0.22,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.16,
doc.width * 0.14,
],
)
)