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

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