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 def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str: if not rates: return locale.t("none") items = [ f"{locale.format_amount(rate['amount'], 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(row['amount'], 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(rate['amount'])} {locale.currency_label(rate['currency'])}" def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str: if not rate: return "-" value = f"{locale.format_amount(rate['amount'], ascii_digits=True)} {locale.currency_label(rate['currency'])}" return f"\u202B{value}\u202C" if locale.is_rtl else value 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 = f"{locale.format_amount(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 if row_name and row["name"] == row_name: return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value return "-" 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), locale.format_date(row["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], 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, ) header_row = worksheet.max_row + 1 headers = [ locale.t("name"), locale.t("billable_hours"), locale.t("non_billable_hours"), locale.t("total_hours"), locale.t("income"), ] 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) 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), 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"]), ] if percentages is not None: values.append(_percentage_display(locale, percentages, row, ascii_digits=True)) 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"], percentages=user_summary["client_percentages"], ) _append_breakdown_table( worksheet, locale=locale, title_key="projects", rows=report_data["projects"], percentages=user_summary["project_percentages"], ) _append_breakdown_table( worksheet, locale=locale, title_key="tags", rows=report_data["tags"], percentages=user_summary["tag_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 = [ [ _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)}", ] 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 []) ] 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, 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]), ], ) 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=13, value=locale.t("users_summary_sheet"), rtl=locale.is_rtl, ) summary_headers = [ locale.t("name"), locale.t("mobile"), locale.t("working_hours"), locale.t("non_working_hours"), locale.t("income"), locale.t("hourly_rate"), locale.t("period"), locale.t("clients"), locale.t("percentage"), locale.t("projects"), locale.t("percentage"), locale.t("tags"), locale.t("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 range(1, 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=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) _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) 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")), ): _merge_and_style(worksheet, row=current_row, start_col=1, end_col=6, 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("non_billable_hours"), locale.t("total_hours"), locale.t("income"), locale.t("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), 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), ], 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, None], rtl=locale.is_rtl, ) current_row += 1 current_row += 1 overall_widths = { "A": 31.57, "B": 19.86, "C": 18.0, "D": 17.0, "E": 24.0, "F": 17.57, "G": 32.0, "H": 30.0, "I": 14.0, "J": 32.86, "K": 12.0, "L": 22.0, "M": 12.0, } for column, width in overall_widths.items(): worksheet.column_dimensions[column].width = width def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -> 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"], percentages=user_summary["client_percentages"] if user_summary else None, ) _append_breakdown_table( worksheet, locale=locale, title_key="projects", rows=report_data["projects"], percentages=user_summary["project_percentages"] if user_summary else None, ) _append_breakdown_table( worksheet, locale=locale, title_key="tags", rows=report_data["tags"], percentages=user_summary["tag_percentages"] if user_summary else None, ) _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) 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"]), locale.format_date(row["to_date"]), ], ) 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.34, doc_width * 0.33, doc_width * 0.33]) 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, ) -> 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") 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 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")) header = _rtl_row(locale, header_values) body_rows = _report_table_rows(locale, rows, is_daily=is_daily) if percentage_rows is not None: body_rows = [ _rtl_row( locale, [ row["name"], locale.format_duration(row["billable_duration"]), 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), ], ) for row in rows ] 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.15, doc_width * 0.13, doc_width * 0.16, doc_width * 0.22, ] if is_daily else [ *( [ doc_width * 0.24, doc_width * 0.13, doc_width * 0.15, doc_width * 0.12, doc_width * 0.2, doc_width * 0.16, ] if percentage_rows is not None else [ doc_width * 0.26, doc_width * 0.15, doc_width * 0.17, doc_width * 0.14, doc_width * 0.28, ] ) ] ), ) 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("non_working_hours"), locale.t("income"), ], ) user_summary_rows = [ _rtl_row( locale, [ summary["user"]["name"], locale.format_number(summary["user"]["mobile"]), locale.format_duration(summary["billable_duration"]), locale.format_duration(summary["non_billable_duration"]), _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.24, doc.width * 0.18, doc.width * 0.18, doc.width * 0.18, doc.width * 0.22, ], ) ) 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"), ) 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"), ) doc.build(story) return buffer.getvalue()