1522 lines
57 KiB
Python
1522 lines
57 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 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()
|