276 lines
9.5 KiB
Python
276 lines
9.5 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
from decimal import Decimal, InvalidOperation
|
|
from pathlib import Path
|
|
|
|
import jdatetime
|
|
from arabic_reshaper import reshape
|
|
from bidi.algorithm import get_display
|
|
|
|
PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
|
ARABIC_RANGES = (
|
|
(0x0600, 0x06FF),
|
|
(0x0750, 0x077F),
|
|
(0x08A0, 0x08FF),
|
|
(0xFB50, 0xFDFF),
|
|
(0xFE70, 0xFEFF),
|
|
)
|
|
|
|
|
|
TRANSLATIONS = {
|
|
"en": {
|
|
"report_title": "Workspace Report",
|
|
"overall_sheet": "Overall Report",
|
|
"users_summary_sheet": "Users Summary",
|
|
"workspace": "Workspace",
|
|
"period": "Period",
|
|
"from_date": "From date",
|
|
"to_date": "To date",
|
|
"user": "User",
|
|
"mobile": "Mobile",
|
|
"all_users": "All users",
|
|
"generated_at": "Generated at",
|
|
"summary": "Summary",
|
|
"total_hours": "Total hours",
|
|
"billable_hours": "Billable hours",
|
|
"non_billable_hours": "Non-billable hours",
|
|
"hourly_rate": "Hourly rate",
|
|
"income": "Income",
|
|
"working_hours": "Working hours",
|
|
"non_working_hours": "Non-working hours",
|
|
"hourly_rates": "Hourly rates",
|
|
"project_percentages": "Project percentages",
|
|
"client_percentages": "Client percentages",
|
|
"tag_percentages": "Tag percentages",
|
|
"summary_by_user": "Summary by user",
|
|
"rate_history": "Hourly rate history",
|
|
"from": "From",
|
|
"to": "To",
|
|
"now": "Now",
|
|
"project": "Project",
|
|
"percentage": "Percentage",
|
|
"hour_percentage": "Hour %",
|
|
"income_percentage": "Income %",
|
|
"multiple_rates": "Multiple rates - see details",
|
|
"variable_rate": "Variable rate",
|
|
"none": "None",
|
|
"daily_summary": "Daily Summary",
|
|
"clients": "Clients",
|
|
"projects": "Projects",
|
|
"tags": "Tags",
|
|
"date": "Date",
|
|
"name": "Name",
|
|
"total": "Total",
|
|
"no_data": "No data",
|
|
"uncategorized_client": "No client",
|
|
"uncategorized_project": "No project",
|
|
"uncategorized_tag": "No tag",
|
|
},
|
|
"fa": {
|
|
"report_title": "گزارش فضای کاری",
|
|
"overall_sheet": "گزارش کلی",
|
|
"users_summary_sheet": "خلاصه کاربران",
|
|
"workspace": "فضای کاری",
|
|
"period": "بازه",
|
|
"from_date": "از تاریخ",
|
|
"to_date": "تا تاریخ",
|
|
"user": "کاربر",
|
|
"mobile": "موبایل",
|
|
"all_users": "همه کاربران",
|
|
"generated_at": "تاریخ تولید",
|
|
"summary": "خلاصه",
|
|
"total_hours": "کل ساعات",
|
|
"billable_hours": "ساعات کاری",
|
|
"non_billable_hours": "ساعات غیر کاری",
|
|
"hourly_rate": "نرخ ساعتی",
|
|
"income": "کارکرد",
|
|
"working_hours": "ساعات کاری",
|
|
"non_working_hours": "ساعات غیرکاری",
|
|
"hourly_rates": "نرخهای ساعتی",
|
|
"project_percentages": "درصد پروژهها",
|
|
"client_percentages": "درصد مشتریها",
|
|
"tag_percentages": "درصد تگها",
|
|
"summary_by_user": "خلاصه کاربران",
|
|
"rate_history": "تاریخچه نرخ ساعتی",
|
|
"from": "از",
|
|
"to": "تا",
|
|
"now": "حال",
|
|
"project": "پروژه",
|
|
"percentage": "درصد",
|
|
"hour_percentage": "درصد ساعت",
|
|
"income_percentage": "درصد کارکرد",
|
|
"multiple_rates": "چند نرخ - جزئیات در گزارش کاربر",
|
|
"variable_rate": "نرخ متغیر",
|
|
"none": "بدون مورد",
|
|
"daily_summary": "خلاصه روزانه",
|
|
"clients": "مشتریان",
|
|
"projects": "پروژهها",
|
|
"tags": "تگها",
|
|
"date": "تاریخ",
|
|
"name": "نام",
|
|
"total": "جمع",
|
|
"no_data": "بدون داده",
|
|
"uncategorized_client": "بدون مشتری",
|
|
"uncategorized_project": "بدون پروژه",
|
|
"uncategorized_tag": "بدون تگ",
|
|
},
|
|
}
|
|
|
|
PERIOD_LABELS = {
|
|
"en": {
|
|
"this_week": "This week",
|
|
"this_month": "This month",
|
|
"this_year": "This year",
|
|
"half_year_first": "First half of year",
|
|
"half_year_second": "Second half of year",
|
|
"period": "Custom period",
|
|
},
|
|
"fa": {
|
|
"this_week": "این هفته",
|
|
"this_month": "این ماه",
|
|
"this_year": "امسال",
|
|
"half_year_first": "نیمه اول سال",
|
|
"half_year_second": "نیمه دوم سال",
|
|
"period": "بازه دلخواه",
|
|
},
|
|
}
|
|
|
|
CURRENCY_LABELS = {
|
|
"USD": {"en": "USD", "fa": "دلار آمریکا"},
|
|
"EUR": {"en": "EUR", "fa": "یورو"},
|
|
"GBP": {"en": "GBP", "fa": "پوند"},
|
|
"IRR": {"en": "IRR", "fa": "ریال"},
|
|
"IRT": {"en": "IRT", "fa": "تومان"},
|
|
"AED": {"en": "AED", "fa": "درهم"},
|
|
"TRY": {"en": "TRY", "fa": "لیر"},
|
|
}
|
|
|
|
DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExportLocale:
|
|
language: str
|
|
is_rtl: bool
|
|
font_regular: str
|
|
font_bold: str
|
|
|
|
def t(self, key: str) -> str:
|
|
return TRANSLATIONS[self.language][key]
|
|
|
|
def period_label(self, period: str) -> str:
|
|
return PERIOD_LABELS[self.language].get(period, period)
|
|
|
|
def format_number(self, value: object, *, ascii_digits: bool = False) -> str:
|
|
text = str(value)
|
|
if self.language == "fa" and not ascii_digits:
|
|
return text.translate(PERSIAN_DIGITS)
|
|
return text
|
|
|
|
def format_date(self, value: date | str | None, *, ascii_digits: bool = False) -> str:
|
|
if value is None:
|
|
return "-"
|
|
if isinstance(value, str):
|
|
value = date.fromisoformat(value)
|
|
if self.language == "fa":
|
|
jalali = jdatetime.date.fromgregorian(date=value)
|
|
return self.format_number(jalali.strftime("%Y/%m/%d"), ascii_digits=ascii_digits)
|
|
return value.strftime("%Y/%m/%d")
|
|
|
|
def format_duration(self, value: str, *, ascii_digits: bool = False) -> str:
|
|
return self.format_number(value, ascii_digits=ascii_digits)
|
|
|
|
def format_amount(self, value: object, *, ascii_digits: bool = False) -> str:
|
|
return self.format_amount_for_currency(value, None, ascii_digits=ascii_digits)
|
|
|
|
def format_amount_for_currency(
|
|
self,
|
|
value: object,
|
|
currency: str | None,
|
|
*,
|
|
ascii_digits: bool = False,
|
|
) -> str:
|
|
raw = str(value).strip()
|
|
if not raw:
|
|
return raw
|
|
try:
|
|
decimal_value = Decimal(raw)
|
|
except InvalidOperation:
|
|
return self.format_number(raw, ascii_digits=ascii_digits)
|
|
|
|
sign = "-" if decimal_value < 0 else ""
|
|
unsigned = abs(decimal_value)
|
|
normalized = format(unsigned, "f")
|
|
integer_part, _, fractional_part = normalized.partition(".")
|
|
grouped_integer = f"{int(integer_part):,}"
|
|
formatted = f"{sign}{grouped_integer}"
|
|
if fractional_part:
|
|
trimmed_fraction = (
|
|
""
|
|
if str(currency or "").upper() in DECIMAL_TRIM_CURRENCIES
|
|
else fractional_part.rstrip("0")
|
|
)
|
|
if trimmed_fraction:
|
|
formatted = f"{formatted}.{trimmed_fraction}"
|
|
return self.format_number(formatted, ascii_digits=ascii_digits)
|
|
|
|
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
|
|
if not income_totals:
|
|
return "-"
|
|
parts = []
|
|
for item in income_totals:
|
|
currency = self.currency_label(item["currency"])
|
|
parts.append(
|
|
f"{self.format_amount_for_currency(item['amount'], item['currency'], ascii_digits=ascii_digits)} {currency}"
|
|
)
|
|
return " | ".join(parts)
|
|
|
|
def currency_label(self, code: str | None) -> str:
|
|
raw = str(code or "").upper()
|
|
return CURRENCY_LABELS.get(raw, {}).get(self.language, raw)
|
|
|
|
def shape(self, text: object) -> str:
|
|
raw = str(text)
|
|
if not any(start <= ord(char) <= end for char in raw for start, end in ARABIC_RANGES):
|
|
return raw
|
|
return get_display(reshape(raw))
|
|
|
|
|
|
def build_export_locale(language: str | None) -> ExportLocale:
|
|
resolved = language if language in {"en", "fa"} else "en"
|
|
assets_dir = Path(__file__).resolve().parent.parent / "assets" / "fonts"
|
|
return ExportLocale(
|
|
language=resolved,
|
|
is_rtl=resolved == "fa",
|
|
font_regular=str(assets_dir / "Vazirmatn-Regular.ttf"),
|
|
font_bold=str(assets_dir / "Vazirmatn-Bold.ttf"),
|
|
)
|
|
|
|
|
|
def user_label(user_payload: dict | None, locale: ExportLocale, *, ascii_digits: bool = False) -> str:
|
|
if not user_payload:
|
|
return locale.t("all_users")
|
|
mobile = locale.format_number(user_payload.get("mobile") or "", ascii_digits=ascii_digits)
|
|
if mobile:
|
|
return f"{user_payload['name']} - {mobile}"
|
|
return str(user_payload["name"])
|
|
|
|
|
|
def safe_sheet_title(title: str, used: Iterable[str]) -> str:
|
|
invalid = set("[]:*?/\\")
|
|
sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet"
|
|
base = sanitized[:31]
|
|
used_set = set(used)
|
|
if base not in used_set:
|
|
return base
|
|
index = 2
|
|
while True:
|
|
suffix = f"-{index}"
|
|
candidate = f"{sanitized[:31 - len(suffix)]}{suffix}"
|
|
if candidate not in used_set:
|
|
return candidate
|
|
index += 1
|