Files
qlockify-backend-deployment/apps/reports/services/exporters.py
Amirhossein Khalili d18fdb1454
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
refactor(reports): replace escaped persian export labels
2026-05-24 11:16:59 +03:30

1463 lines
54 KiB
Python

from __future__ import annotations
import io
import os
from datetime import datetime
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Image as RLImage
from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from apps.reports.services.export_i18n import ExportLocale, safe_sheet_title, user_label
HEADER_FILL = PatternFill("solid", fgColor="E7F2FF")
SECTION_FILL = PatternFill("solid", fgColor="F8FAFC")
BORDER = Border(
left=Side(style="thin", color="D0D7DE"),
right=Side(style="thin", color="D0D7DE"),
top=Side(style="thin", color="D0D7DE"),
bottom=Side(style="thin", color="D0D7DE"),
)
class BookmarkDocTemplate(SimpleDocTemplate):
def afterFlowable(self, flowable) -> None: # pragma: no cover - reportlab integration hook
bookmark_name = getattr(flowable, "_bookmark_name", None)
bookmark_title = getattr(flowable, "_bookmark_title", None)
if not bookmark_name or not bookmark_title:
return
self.canv.bookmarkPage(bookmark_name)
self.canv.addOutlineEntry(bookmark_title, bookmark_name, level=0, closed=False)
def _register_pdf_fonts(locale: ExportLocale) -> None:
registered = set(pdfmetrics.getRegisteredFontNames())
if "Vazirmatn" not in registered:
pdfmetrics.registerFont(TTFont("Vazirmatn", locale.font_regular))
if "Vazirmatn-Bold" not in registered:
pdfmetrics.registerFont(TTFont("Vazirmatn-Bold", locale.font_bold))
def _apply_cell_style(cell, *, bold: bool = False, fill=None, rtl: bool = False) -> None:
cell.font = Font(name="Calibri", bold=bold, size=11)
cell.border = BORDER
cell.alignment = Alignment(horizontal="right" if rtl else "left", vertical="center", wrap_text=True)
if fill is not None:
cell.fill = fill
def _autosize_columns(worksheet) -> None:
widths: dict[int, int] = {}
for row in worksheet.iter_rows():
for cell in row:
if cell.value is None:
continue
widths[cell.column] = max(widths.get(cell.column, 0), len(str(cell.value)))
for column_index, width in widths.items():
worksheet.column_dimensions[get_column_letter(column_index)].width = min(max(width + 4, 12), 30)
def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str:
return locale.format_money_label(income_totals)
def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
value = locale.format_money_label(income_totals, ascii_digits=True)
return f"\u202B{value}\u202C" if locale.is_rtl else value # Unicode bidi control characters
def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str:
if not rates:
return "-"
items = [
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}"
for rate in rates
]
return ", ".join(items)
def _percentages_label(locale: ExportLocale, rows: list[dict], *, ascii_digits: bool = False) -> str:
if not rows:
return locale.t("none")
items = [
f"{locale.format_amount(row['percentage'], ascii_digits=ascii_digits)}% {row['name']}"
for row in rows
]
return ", ".join(items)
def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool = False) -> str:
return (
f"{locale.format_amount_for_currency(row['amount'], row['currency'], ascii_digits=ascii_digits)} "
f"{locale.currency_label(row['currency'])}"
)
def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
if not rate:
return "-"
return f"{locale.format_amount_for_currency(rate['amount'], rate['currency'])} {locale.currency_label(rate['currency'])}"
def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str:
if not rate:
return "-"
value = (
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=True)} "
f"{locale.currency_label(rate['currency'])}"
)
return f"\u202B{value}\u202C" if locale.is_rtl else value # Unicode bidi control characters
def _pdf_summary_rate_label(locale: ExportLocale, rates: list[dict]) -> str:
if len(rates) > 1:
return locale.t("variable_rate")
return _rates_label(locale, rates)
def _summary_rate_rows(locale: ExportLocale, summary: dict) -> list[list[str]]:
rate_periods = summary.get("rate_periods") or []
if not rate_periods:
return [[locale.t("none"), locale.t("none")]]
if len(rate_periods) > 1:
return [[locale.t("variable_rate"), _summary_period_label(locale, rate_periods, ascii_digits=True)]]
row = rate_periods[0]
return [
[
_rate_period_label(locale, row, ascii_digits=True),
(
f"{locale.format_date(row['from_date'], ascii_digits=True)} - "
f"{locale.format_date(row['to_date'], ascii_digits=True)}"
),
]
]
def _section_headers(locale: ExportLocale) -> list[str]:
headers = [
locale.t("name"),
locale.t("billable_hours"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("income"),
]
return list(reversed(headers)) if locale.is_rtl else headers
def _rtl_row(locale: ExportLocale, row: list[str]) -> list[str]:
return list(reversed(row)) if locale.is_rtl else row
def _excel_table_row(row: list[str]) -> list[str]:
return row
def _excel_pair_row(row: list[str]) -> list[str]:
return row
def _compact_summary_headers(locale: ExportLocale) -> list[str]:
return _excel_table_row(
[
locale.t("name"),
locale.t("mobile"),
locale.t("working_hours"),
locale.t("non_working_hours"),
locale.t("income"),
],
)
def _compact_summary_row(locale: ExportLocale, user_summary: dict) -> list[str]:
return _excel_table_row(
[
user_summary["user"]["name"],
locale.format_number(user_summary["user"]["mobile"], ascii_digits=True),
locale.format_duration(user_summary["billable_duration"], ascii_digits=True),
locale.format_duration(user_summary["non_billable_duration"], ascii_digits=True),
_money_label_excel(locale, user_summary["income_totals"]),
],
)
def _append_user_summary_block(worksheet, *, locale: ExportLocale, user_summary: dict) -> None:
worksheet.append([])
worksheet.append([locale.t("summary_by_user")])
for row in (
_excel_pair_row([locale.t("user"), user_summary["user"]["name"]]),
_excel_pair_row(
[locale.t("mobile"), locale.format_number(user_summary["user"]["mobile"], ascii_digits=True)],
),
_excel_pair_row(
[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,
),
],
),
_excel_pair_row([locale.t("income"), _money_label_excel(locale, user_summary["income_totals"])]),
):
worksheet.append(row)
def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict, *, ascii_digits: bool = False) -> str:
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 = _percentage_value(locale, row["percentage"], ascii_digits=ascii_digits)
if row_id is not None and str(row["id"]) == row_id:
return value
if row_name and row["name"] == row_name:
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 # Unicode bidi control characters
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]
last_to_date = last_row.get("to_date")
return (
f"{locale.format_date(first_row['from_date'], ascii_digits=ascii_digits)} - "
f"{(_rate_to_label(locale, last_to_date, ascii_digits=ascii_digits))}"
)
def _rate_to_label(locale: ExportLocale, to_date: str | None, *, ascii_digits: bool = False) -> str:
if not to_date:
return locale.t("now")
return locale.format_date(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)
header_row = worksheet.max_row + 1
worksheet.append(
_excel_table_row(
[locale.t("hourly_rate"), locale.t("from"), locale.t("to")],
)
)
for cell in worksheet[header_row]:
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
rate_periods = user_summary.get("rate_periods") or []
if not rate_periods:
worksheet.append([locale.t("no_data")])
_apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl)
return
for row in rate_periods:
worksheet.append(
_excel_table_row(
[
_rate_period_label(locale, row, ascii_digits=True),
locale.format_date(row["from_date"], ascii_digits=True),
_rate_to_label(locale, row.get("to_date"), ascii_digits=True),
],
)
)
for cell in worksheet[worksheet.max_row]:
_apply_cell_style(cell, rtl=locale.is_rtl)
def _append_percentage_table_excel(worksheet, *, locale: ExportLocale, title_key: str, rows: list[dict]) -> None:
worksheet.append([])
_append_merged_heading(worksheet, locale=locale, title=locale.t(title_key), span=2)
header_row = worksheet.max_row + 1
worksheet.append(_excel_table_row([locale.t("name"), locale.t("percentage")]))
for cell in worksheet[header_row]:
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
if not rows:
worksheet.append([locale.t("no_data")])
_apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl)
return
for row in rows:
worksheet.append(
_excel_table_row([row["name"], f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"])
)
for cell in worksheet[worksheet.max_row]:
_apply_cell_style(cell, rtl=locale.is_rtl)
def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) -> None:
scope = report_data["scope"]
summary = report_data["summary"]
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("user"),
scope["user"]["name"] if scope.get("user") else locale.t("all_users"),
],
)
)
worksheet.append(
_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 "-"
),
],
)
)
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("income"), _money_label_excel(locale, summary["income_totals"])]))
for row_index in range(1, worksheet.max_row + 1):
first_cell = worksheet.cell(row=row_index, column=1)
second_cell = worksheet.cell(row=row_index, column=2)
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,
)
elif first_cell.value:
_apply_cell_style(first_cell, bold=False, fill=None, rtl=locale.is_rtl)
if second_cell.value:
_apply_cell_style(second_cell, rtl=locale.is_rtl)
def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) -> None:
worksheet.append([])
_append_merged_heading(worksheet, locale=locale, title=locale.t("daily_summary"), span=6)
header_row = worksheet.max_row + 1
worksheet.append(
_excel_table_row(
[
locale.t("date"),
locale.t("billable_hours"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("hourly_rate"),
locale.t("income"),
],
)
)
for cell in worksheet[header_row]:
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
if not report_data["days"]:
worksheet.append([locale.t("no_data")])
_apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl)
return
for row in report_data["days"]:
worksheet.append(
_excel_table_row(
[
locale.format_date(row["date"], ascii_digits=True),
locale.format_duration(row["billable_duration"], ascii_digits=True),
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
locale.format_duration(row["total_duration"], ascii_digits=True),
_rate_label_excel(locale, row.get("latest_hourly_rate")),
_money_label_excel(locale, row["income_totals"]),
],
)
)
for cell in worksheet[worksheet.max_row]:
_apply_cell_style(cell, rtl=locale.is_rtl)
worksheet.append(
_excel_table_row(
[
locale.t("total"),
locale.format_duration(report_data["summary"]["billable_duration"], ascii_digits=True),
locale.format_duration(report_data["summary"]["non_billable_duration"], ascii_digits=True),
locale.format_duration(report_data["summary"]["total_duration"], ascii_digits=True),
"-",
_money_label_excel(locale, report_data["summary"]["income_totals"]),
],
)
)
for cell in worksheet[worksheet.max_row]:
_apply_cell_style(cell, bold=True, fill=SECTION_FILL, rtl=locale.is_rtl)
def _append_breakdown_table(
worksheet,
*,
locale: ExportLocale,
title_key: str,
rows: list[dict],
hour_percentages: list[dict] | None = None,
income_percentages: list[dict] | None = None,
financial_only: bool = False,
) -> None:
worksheet.append([])
_append_merged_heading(
worksheet,
locale=locale,
title=locale.t(title_key),
span=(
5
if hour_percentages is not None and financial_only
else 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 [] ),
*( [] if financial_only else [locale.t("non_billable_hours"), locale.t("total_hours")] ),
locale.t("income"),
*( [locale.t("income_percentage")] if hour_percentages is not None else [] ),
]
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)
if not rows:
worksheet.append([locale.t("no_data")])
_apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl)
return
for row in rows:
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 []
),
*(
[]
if financial_only
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 []
),
]
worksheet.append(_excel_table_row(values))
for cell in worksheet[worksheet.max_row]:
_apply_cell_style(cell, rtl=locale.is_rtl)
def _append_user_details_block_excel(
worksheet,
*,
locale: ExportLocale,
report_data: dict,
leading_blank: bool = True,
) -> None:
user_summary = report_data["user_summary"]
if leading_blank:
worksheet.append([])
_append_merged_heading(worksheet, locale=locale, title=user_summary["user"]["name"], span=6)
_append_rate_history_table_excel(worksheet, locale=locale, user_summary=user_summary)
_append_breakdown_table(
worksheet,
locale=locale,
title_key="clients",
rows=report_data["clients"],
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"],
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"],
hour_percentages=user_summary["tag_percentages"],
income_percentages=user_summary["tag_income_percentages"],
)
def _merge_and_style(worksheet, *, row: int, start_col: int, end_col: int, value: str, rtl: bool) -> None:
worksheet.merge_cells(start_row=row, start_column=start_col, end_row=row, end_column=end_col)
cell = worksheet.cell(row=row, column=start_col)
cell.value = value
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=rtl)
def _append_merged_heading(worksheet, *, locale: ExportLocale, title: str, span: int) -> int:
worksheet.append([title])
row = worksheet.max_row
if span > 1:
worksheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=span)
_apply_cell_style(worksheet.cell(row=row, column=1), bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
return row
def _write_table_row(
worksheet,
*,
row: int,
start_col: int,
values: list[str | None],
rtl: bool,
bold: bool = False,
fill=None,
) -> None:
for offset, value in enumerate(values):
cell = worksheet.cell(row=row, column=start_col + offset)
cell.value = value
_apply_cell_style(cell, bold=bold, fill=fill, rtl=rtl)
def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, list[list[str | None]]]:
rate_rows = _summary_rate_rows(locale, summary)
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):
rows.append(
[
summary["user"]["name"] if index == 0 else None,
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,
*(rate_rows[index] if index < len(rate_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
def _merge_vertical_if_needed(worksheet, *, start_row: int, span: int, column: int, value_present: bool = True) -> None:
if span > 1 and value_present:
worksheet.merge_cells(
start_row=start_row,
start_column=column,
end_row=start_row + span - 1,
end_column=column,
)
def _render_all_users_overall_excel_sheet(
worksheet,
*,
locale: ExportLocale,
report_data: dict,
) -> None:
if locale.is_rtl:
worksheet.sheet_view.rightToLeft = True
scope = report_data["scope"]
summary = report_data["summary"]
top_rows = [
[locale.t("report_title"), scope["workspace"]["name"]],
[locale.t("period"), locale.period_label(scope["period"])],
[locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)],
[locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)],
[locale.t("user"), locale.t("all_users")],
[locale.t("mobile"), "-"],
[locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)],
]
for row_index, values in enumerate(top_rows, start=1):
_write_table_row(
worksheet,
row=row_index,
start_col=1,
values=values,
rtl=locale.is_rtl,
bold=(row_index == 1),
fill=HEADER_FILL if row_index == 1 else None,
)
_merge_and_style(worksheet, row=9, start_col=1, end_col=2, value=locale.t("summary"), rtl=locale.is_rtl)
summary_rows = [
[locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)],
[locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)],
[locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)],
[locale.t("income"), _money_label_excel(locale, summary["income_totals"])],
]
for row_index, values in enumerate(summary_rows, start=10):
_write_table_row(worksheet, row=row_index, start_col=1, values=values, rtl=locale.is_rtl)
_merge_and_style(
worksheet,
row=15,
start_col=1,
end_col=15,
value=locale.t("users_summary_sheet"),
rtl=locale.is_rtl,
)
summary_headers = [
locale.t("name"),
locale.t("mobile"),
locale.t("working_hours"),
locale.t("hourly_rate"),
locale.t("period"),
locale.t("income"),
locale.t("clients"),
locale.t("hour_percentage"),
locale.t("income_percentage"),
locale.t("projects"),
locale.t("hour_percentage"),
locale.t("income_percentage"),
locale.t("tags"),
locale.t("hour_percentage"),
locale.t("income_percentage"),
]
_write_table_row(
worksheet,
row=16,
start_col=1,
values=summary_headers,
rtl=locale.is_rtl,
bold=True,
fill=HEADER_FILL,
)
current_row = 17
for user_summary in report_data["user_summaries"]:
span, rows = _user_summary_row_payload(locale, user_summary)
for offset, values in enumerate(rows):
_write_table_row(
worksheet,
row=current_row + offset,
start_col=1,
values=values,
rtl=locale.is_rtl,
)
for column in (1, 2, 3, 6):
_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=4, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True)
if len(client_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=7, value_present=True)
_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)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True)
if len(tag_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True)
_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)
current_row += span
current_row += 2
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=7,
value=locale.t(title_key),
rtl=locale.is_rtl,
)
current_row += 1
_write_table_row(
worksheet,
row=current_row,
start_col=1,
values=[
locale.t("name"),
locale.t("billable_hours"),
locale.t("hour_percentage"),
locale.t("income"),
locale.t("income_percentage"),
],
rtl=locale.is_rtl,
bold=True,
fill=HEADER_FILL,
)
current_row += 1
if rows:
for row in rows:
_write_table_row(
worksheet,
row=current_row,
start_col=1,
values=[
row["name"],
locale.format_duration(row["billable_duration"], ascii_digits=True),
_percentage_display(locale, hour_percentages or [], row, ascii_digits=True),
_money_label_excel(locale, row["income_totals"]),
_percentage_display(locale, income_percentages or [], row, ascii_digits=True),
],
rtl=locale.is_rtl,
)
current_row += 1
else:
_write_table_row(
worksheet,
row=current_row,
start_col=1,
values=[locale.t("no_data"), None, None, None, None],
rtl=locale.is_rtl,
)
current_row += 1
current_row += 1
overall_widths = {
"A": 31.57,
"B": 19.86,
"C": 18.0,
"D": 18.0,
"E": 26.0,
"F": 24.0,
"G": 28.0,
"H": 14.0,
"I": 16.0,
"J": 28.0,
"K": 14.0,
"L": 16.0,
"M": 24.0,
"N": 14.0,
"O": 16.0,
}
for column, width in overall_widths.items():
worksheet.column_dimensions[column].width = width
def _render_excel_sheet(
worksheet,
*,
locale: ExportLocale,
report_data: dict,
financial_only_breakdowns: bool = False,
) -> None:
if locale.is_rtl:
worksheet.sheet_view.rightToLeft = True
worksheet.freeze_panes = "E4"
else:
worksheet.freeze_panes = "A4"
_append_meta_block(worksheet, locale=locale, report_data=report_data)
if report_data.get("user_summaries"):
worksheet.append([])
_append_all_users_summary_sheet(
worksheet,
locale=locale,
user_summaries=report_data["user_summaries"],
)
return
if report_data.get("user_summary"):
_append_rate_history_table_excel(worksheet, locale=locale, user_summary=report_data["user_summary"])
_append_daily_table(worksheet, locale=locale, report_data=report_data)
user_summary = report_data.get("user_summary")
_append_breakdown_table(
worksheet,
locale=locale,
title_key="clients",
rows=report_data["clients"],
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")
),
financial_only=financial_only_breakdowns,
)
_append_breakdown_table(
worksheet,
locale=locale,
title_key="projects",
rows=report_data["projects"],
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")
),
financial_only=financial_only_breakdowns,
)
_append_breakdown_table(
worksheet,
locale=locale,
title_key="tags",
rows=report_data["tags"],
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")
),
financial_only=financial_only_breakdowns,
)
_autosize_columns(worksheet)
def _append_all_users_summary_sheet(worksheet, *, locale: ExportLocale, user_summaries: list[dict]) -> None:
if locale.is_rtl:
worksheet.sheet_view.rightToLeft = True
title_row = worksheet.max_row + 1
worksheet.append([locale.t("users_summary_sheet")])
header_row = worksheet.max_row + 1
headers = _compact_summary_headers(locale)
worksheet.append(headers)
for cell in worksheet[header_row]:
_apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
for summary in user_summaries:
worksheet.append(_compact_summary_row(locale, summary))
for cell in worksheet[worksheet.max_row]:
_apply_cell_style(cell, rtl=locale.is_rtl)
_apply_cell_style(worksheet.cell(row=title_row, column=1), bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_reports: list[dict] | None = None) -> bytes:
workbook = Workbook()
used_titles: set[str] = set()
if report_data.get("user_summaries") and per_user_reports:
overall_sheet = workbook.active
overall_sheet.title = safe_sheet_title(locale.t("overall_sheet"), used_titles)
_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,
)
worksheet = workbook.create_sheet(title=user_title)
_render_excel_sheet(
worksheet,
locale=locale,
report_data=user_report,
financial_only_breakdowns=True,
)
used_titles.add(user_title)
else:
overall_sheet = workbook.active
overall_sheet.title = safe_sheet_title(locale.t("overall_sheet"), used_titles)
_render_excel_sheet(overall_sheet, locale=locale, report_data=report_data)
used_titles.add(overall_sheet.title)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def _paragraph(text: str, style: ParagraphStyle, locale: ExportLocale) -> Paragraph:
return Paragraph(locale.shape(text), style)
def _bookmark_paragraph(text: str, style: ParagraphStyle, locale: ExportLocale, bookmark_name: str) -> Paragraph:
paragraph = _paragraph(text, style, locale)
paragraph._bookmark_name = bookmark_name
paragraph._bookmark_title = text
return paragraph
def _workspace_initial(name: str) -> str:
stripped = (name or "").strip()
return stripped[0].upper() if stripped else "W"
def _styled_table(data: list[list[str]], *, locale: ExportLocale, column_widths: list[float]) -> Table:
shaped_data = [
[locale.shape(cell) if cell is not None else "" for cell in row]
for row in data
]
table = Table(shaped_data, colWidths=column_widths, repeatRows=1)
table.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (-1, 0), "Vazirmatn-Bold" if locale.language == "fa" else "Helvetica-Bold"),
("FONTNAME", (0, 1), (-1, -1), "Vazirmatn" if locale.language == "fa" else "Helvetica"),
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E7F2FF")),
("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor("#0F172A")),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#CBD5E1")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]
)
)
return table
def _report_table_rows(locale: ExportLocale, rows: list[dict], *, is_daily: bool) -> list[list[str]]:
if not rows:
column_count = 6 if is_daily else 5
return [_rtl_row(locale, [locale.t("no_data"), *([""] * (column_count - 1))])]
return [
_rtl_row(
locale,
[
locale.format_date(row.get("date")) if row.get("date") else row["name"],
locale.format_duration(row["billable_duration"]),
locale.format_duration(row["non_billable_duration"]),
locale.format_duration(row["total_duration"]),
*([_rate_label(locale, row.get("latest_hourly_rate"))] if is_daily else []),
_money_label(locale, row["income_totals"]),
],
)
for row in rows
]
def _build_pdf_user_summary_table(locale: ExportLocale, summary: dict, doc_width: float) -> Table:
summary_data = [
[locale.t("user"), summary["user"]["name"]],
[locale.t("mobile"), locale.format_number(summary["user"]["mobile"])],
[locale.t("working_hours"), locale.format_duration(summary["billable_duration"])],
[locale.t("non_working_hours"), locale.format_duration(summary["non_billable_duration"])],
[locale.t("income"), _money_label(locale, summary["income_totals"])],
]
if locale.is_rtl:
summary_data = [_rtl_row(locale, row) for row in summary_data]
summary_data = [[locale.shape(cell) for cell in row] for row in summary_data]
table = Table(summary_data, colWidths=[doc_width * 0.3, doc_width * 0.7])
table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#EFF6FF")),
("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#BFDBFE")),
("INNERGRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#BFDBFE")),
("FONTNAME", (0, 0), (0, -1), "Vazirmatn-Bold" if locale.language == "fa" else "Helvetica-Bold"),
("FONTNAME", (1, 0), (1, -1), "Vazirmatn" if locale.language == "fa" else "Helvetica"),
("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]
)
)
return table
def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width: float) -> Table:
rows = summary.get("rate_periods") or []
data = [
_rtl_row(locale, [locale.t("hourly_rate"), locale.t("from"), locale.t("to")]),
*(
_rtl_row(
locale,
[
_rate_period_label(locale, row),
locale.format_date(row["from_date"]),
_rate_to_label(locale, row.get("to_date")),
],
)
for row in rows
),
]
if not rows:
data.append(_rtl_row(locale, [locale.t("no_data"), "", ""]))
fixed_widths = [doc_width * 0.18, doc_width * 0.18]
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
if locale.is_rtl:
column_widths = list(reversed(column_widths))
return _styled_table(
data,
locale=locale,
column_widths=column_widths,
)
def _build_pdf_percentage_table(locale: ExportLocale, rows: list[dict], doc_width: float) -> Table:
data = [
_rtl_row(locale, [locale.t("name"), locale.t("percentage")]),
*(
_rtl_row(
locale,
[row["name"], f"{locale.format_amount(row['percentage'])}%"],
)
for row in rows
),
]
if not rows:
data.append(_rtl_row(locale, [locale.t("no_data"), ""]))
return _styled_table(data, locale=locale, column_widths=[doc_width * 0.7, doc_width * 0.3])
def _append_pdf_report_sections(
*,
story: list,
locale: ExportLocale,
report_data: dict,
doc_width: float,
section_style: ParagraphStyle,
user_summary: dict | None = None,
financial_only_breakdowns: bool = False,
) -> None:
sections = [
("daily_summary", report_data["days"], True),
("clients", report_data["clients"], False),
("projects", report_data["projects"], False),
("tags", report_data["tags"], False),
]
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"),
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"),
*( [] if financial_only_breakdowns else [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:
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 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),
*(
[]
if financial_only_breakdowns
else [
locale.format_duration(row["non_billable_duration"]),
locale.format_duration(row["total_duration"]),
]
),
_money_label(locale, row["income_totals"]),
_percentage_display(locale, income_percentage_rows or [], row),
],
)
for row in rows
] or [
_rtl_row(
locale,
[locale.t("no_data"), "", "", *( [] if financial_only_breakdowns else ["", ""] ), "", ""],
)
]
if is_daily:
column_widths = [
doc_width * 0.20,
doc_width * 0.12,
doc_width * 0.15,
doc_width * 0.13,
doc_width * 0.16,
doc_width * 0.24,
]
elif hour_percentage_rows is not None:
fixed_widths = (
[
doc_width * 0.11,
doc_width * 0.11,
doc_width * 0.19,
doc_width * 0.15,
]
if financial_only_breakdowns
else [
doc_width * 0.11,
doc_width * 0.11,
doc_width * 0.12,
doc_width * 0.12,
doc_width * 0.19,
doc_width * 0.15,
]
)
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
if locale.is_rtl:
column_widths = list(reversed(column_widths))
else:
fixed_widths = [
doc_width * 0.15,
doc_width * 0.17,
doc_width * 0.14,
doc_width * 0.28,
]
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
if locale.is_rtl:
column_widths = list(reversed(column_widths))
table = _styled_table(
[header, *body_rows],
locale=locale,
column_widths=column_widths,
)
story.extend([table, Spacer(1, 5 * mm)])
def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_reports: list[dict] | None = None) -> bytes:
_register_pdf_fonts(locale)
font_regular = "Vazirmatn"
font_bold = "Vazirmatn-Bold"
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
"ReportTitle",
parent=styles["Heading1"],
fontName=font_bold,
fontSize=18,
leading=24,
alignment=2 if locale.is_rtl else 0,
textColor=colors.HexColor("#0F172A"),
)
section_style = ParagraphStyle(
"ReportSection",
parent=styles["Heading3"],
fontName=font_bold,
fontSize=12,
leading=16,
alignment=2 if locale.is_rtl else 0,
textColor=colors.HexColor("#0F172A"),
)
buffer = io.BytesIO()
doc = BookmarkDocTemplate(
buffer,
pagesize=landscape(A4),
leftMargin=14 * mm,
rightMargin=14 * mm,
topMargin=14 * mm,
bottomMargin=14 * mm,
)
scope = report_data["scope"]
summary = report_data["summary"]
workspace_name = scope["workspace"]["name"]
workspace_thumbnail_path = scope["workspace"].get("thumbnail_path")
title_text = f"{locale.t('report_title')} - {workspace_name}"
title_cell = _paragraph(title_text, title_style, locale)
badge_cell = _paragraph(_workspace_initial(workspace_name), title_style, locale)
if workspace_thumbnail_path and os.path.exists(workspace_thumbnail_path):
try:
badge_cell = RLImage(workspace_thumbnail_path, width=14 * mm, height=14 * mm)
except Exception: # noqa: BLE001
badge_cell = _paragraph(_workspace_initial(workspace_name), title_style, locale)
header_row = [badge_cell, title_cell] if locale.is_rtl else [title_cell, badge_cell]
header_col_widths = [doc.width * 0.08, doc.width * 0.92] if locale.is_rtl else [doc.width * 0.92, doc.width * 0.08]
header_table = Table([header_row], colWidths=header_col_widths)
header_table.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
]
)
)
story = [header_table, Spacer(1, 6 * mm)]
meta_rows = [
[locale.t("workspace"), scope["workspace"]["name"]],
[locale.t("period"), locale.period_label(scope["period"])],
[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("generated_at"), locale.format_date(datetime.now().date())],
]
if locale.is_rtl:
meta_rows = [_rtl_row(locale, row) for row in meta_rows]
meta_rows = [[locale.shape(cell) for cell in row] for row in meta_rows]
meta_table = Table(meta_rows, colWidths=[doc.width * 0.24, doc.width * 0.76])
meta_table.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (0, -1), font_bold),
("FONTNAME", (1, 0), (1, -1), font_regular),
("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#F8FAFC")),
("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#CBD5E1")),
("INNERGRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#CBD5E1")),
("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]
)
)
story.extend([meta_table, Spacer(1, 5 * mm)])
summary_data = [
[locale.t("total_hours"), locale.format_duration(summary["total_duration"])],
[locale.t("billable_hours"), locale.format_duration(summary["billable_duration"])],
[locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"])],
[locale.t("income"), _money_label(locale, summary["income_totals"])],
]
if locale.is_rtl:
summary_data = [_rtl_row(locale, row) for row in summary_data]
summary_data = [[locale.shape(cell) for cell in row] for row in summary_data]
summary_table = Table(summary_data, colWidths=[doc.width * 0.38, doc.width * 0.62])
summary_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#EFF6FF")),
("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#BFDBFE")),
("INNERGRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#BFDBFE")),
("FONTNAME", (0, 0), (0, -1), font_bold),
("FONTNAME", (1, 0), (1, -1), font_regular),
("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]
)
)
story.extend([summary_table, Spacer(1, 6 * mm)])
if report_data.get("user_summaries") and per_user_reports:
story.append(_bookmark_paragraph(locale.t("summary_by_user"), section_style, locale, "users-summary"))
story.append(Spacer(1, 2 * mm))
user_summary_header = _rtl_row(
locale,
[
locale.t("name"),
locale.t("mobile"),
locale.t("working_hours"),
locale.t("hourly_rate"),
locale.t("income"),
],
)
user_summary_rows = [
_rtl_row(
locale,
[
summary["user"]["name"],
locale.format_number(summary["user"]["mobile"]),
locale.format_duration(summary["billable_duration"]),
_pdf_summary_rate_label(locale, summary.get("hourly_rates") or []),
_money_label(locale, summary["income_totals"]),
],
)
for summary in report_data["user_summaries"]
]
story.append(
_styled_table(
[user_summary_header, *user_summary_rows],
locale=locale,
column_widths=[
doc.width * 0.25,
doc.width * 0.16,
doc.width * 0.16,
doc.width * 0.19,
doc.width * 0.24,
],
)
)
story.append(Spacer(1, 5 * mm))
for index, user_report in enumerate(per_user_reports):
story.append(PageBreak())
scope_user = user_report["scope"].get("user")
bookmark_suffix = (scope_user.get("id") or scope_user.get("mobile")) if scope_user else index
bookmark_name = f"user-report-{bookmark_suffix}"
story.append(
_bookmark_paragraph(
user_label(user_report["scope"].get("user"), locale),
section_style,
locale,
bookmark_name,
)
)
story.append(Spacer(1, 2 * mm))
if user_report.get("user_summary"):
story.append(_build_pdf_user_summary_table(locale, user_report["user_summary"], doc.width))
story.append(Spacer(1, 5 * mm))
story.append(_paragraph(locale.t("rate_history"), section_style, locale))
story.append(Spacer(1, 2 * mm))
story.append(_build_pdf_rate_history_table(locale, user_report["user_summary"], doc.width))
story.append(Spacer(1, 5 * mm))
_append_pdf_report_sections(
story=story,
locale=locale,
report_data=user_report,
doc_width=doc.width,
section_style=section_style,
user_summary=user_report.get("user_summary"),
financial_only_breakdowns=True,
)
else:
_append_pdf_report_sections(
story=story,
locale=locale,
report_data=report_data,
doc_width=doc.width,
section_style=section_style,
user_summary=report_data.get("user_summary"),
financial_only_breakdowns=False,
)
doc.build(story)
return buffer.getvalue()