Files
qlockify-backend-deployment/apps/reports/services/export_i18n.py

211 lines
7.0 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Iterable
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",
"workspace": "Workspace",
"period": "Period",
"from_date": "From date",
"to_date": "To date",
"user": "User",
"all_users": "All users",
"generated_at": "Generated at",
"summary": "Summary",
"total_hours": "Total hours",
"billable_hours": "Billable hours",
"non_billable_hours": "Non-billable hours",
"income": "Income",
"daily_summary": "Daily Summary",
"clients": "Clients",
"projects": "Projects",
"tags": "Tags",
"date": "Date",
"name": "Name",
"total": "Total",
"no_data": "No data",
},
"fa": {
"report_title": "گزارش فضای کاری",
"overall_sheet": "گزارش کلی",
"workspace": "فضای کاری",
"period": "بازه",
"from_date": "از تاریخ",
"to_date": "تا تاریخ",
"user": "کاربر",
"all_users": "همه کاربران",
"generated_at": "تاریخ تولید",
"summary": "خلاصه",
"total_hours": "کل ساعات",
"billable_hours": "ساعات کاری",
"non_billable_hours": "ساعات غیر کاری",
"income": "درآمد",
"daily_summary": "خلاصه روزانه",
"clients": "مشتریان",
"projects": "پروژه‌ها",
"tags": "تگ‌ها",
"date": "تاریخ",
"name": "نام",
"total": "جمع",
"no_data": "بدون داده",
},
}
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": "لیر"},
}
@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:
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 = 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(item['amount'], 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