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 locale.format_number("0", ascii_digits=ascii_digits) 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 locale.format_number("0") 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 locale.format_number("0", ascii_digits=True) 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, default: str = "0%", ) -> 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 default def _percentage_number(rows: list[dict] | None, row_data: dict) -> float: 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 or []: if row_id is not None and str(row.get("id")) == row_id: try: return float(row.get("percentage") or 0) except (TypeError, ValueError): return 0.0 if row_name and row.get("name") == row_name: try: return float(row.get("percentage") or 0) except (TypeError, ValueError): return 0.0 return 0.0 def _percentage_sort_value(row: dict) -> float: try: return float(row.get("percentage") or 0) except (TypeError, ValueError): return 0.0 def _sort_breakdown_rows(rows: list[dict], hour_percentages: list[dict] | None) -> list[dict]: return sorted( rows, key=lambda row: ( -_percentage_number(hour_percentages, row), -(row.get("billable_seconds") or 0), row.get("name") or "", ), ) 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, default="-"), ] for row in sorted(hour_rows, key=lambda row: (-_percentage_sort_value(row), row.get("name") or "")) ] 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=4) header_row = worksheet.max_row + 1 worksheet.append( _excel_table_row( [locale.t("hourly_rate"), locale.t("from"), locale.t("to"), locale.t("project")], ) ) 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), row.get("project_name") or "-", ], ) ) 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 _sort_breakdown_rows(rows, hour_percentages): 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, default="-")] 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, None, *(client_rows[index] if index < len(client_rows) else [None, None, None]), None, *(project_rows[index] if index < len(project_rows) else [None, 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=18, 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=8, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True) _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=12, value_present=True) _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) if len(tag_rows) == 1: _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=16, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=17, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=18, value_present=True) current_row += span for row_index in range(16, current_row): for column_index in (7, 11, 15): cell = worksheet.cell(row=row_index, column=column_index) cell.value = None cell.fill = PatternFill(fill_type=None) cell.border = Border() 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 sorted_rows = _sort_breakdown_rows(rows, hour_percentages) if sorted_rows: for row in sorted_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, default="-"), ], 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"), locale.t("project")]), *( _rtl_row( locale, [ _rate_period_label(locale, row), locale.format_date(row["from_date"]), _rate_to_label(locale, row.get("to_date")), row.get("project_name") or "-", ], ) 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, doc_width * 0.24] 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) sorted_rows = rows if is_daily else _sort_breakdown_rows(rows, hour_percentage_rows) body_rows = _report_table_rows(locale, sorted_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, default="-"), ], ) for row in sorted_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()