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

251 lines
10 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", "\u06f0\u06f1\u06f2\u06f3\u06f4\u06f5\u06f6\u06f7\u06f8\u06f9")
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",
"percentage": "Percentage",
"hour_percentage": "Hour %",
"income_percentage": "Income %",
"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": "\u06af\u0632\u0627\u0631\u0634 \u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
"overall_sheet": "\u06af\u0632\u0627\u0631\u0634 \u06a9\u0644\u06cc",
"users_summary_sheet": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
"workspace": "\u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
"period": "\u0628\u0627\u0632\u0647",
"from_date": "\u0627\u0632 \u062a\u0627\u0631\u06cc\u062e",
"to_date": "\u062a\u0627 \u062a\u0627\u0631\u06cc\u062e",
"user": "\u06a9\u0627\u0631\u0628\u0631",
"mobile": "\u0645\u0648\u0628\u0627\u06cc\u0644",
"all_users": "\u0647\u0645\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
"generated_at": "\u062a\u0627\u0631\u06cc\u062e \u062a\u0648\u0644\u06cc\u062f",
"summary": "\u062e\u0644\u0627\u0635\u0647",
"total_hours": "\u06a9\u0644 \u0633\u0627\u0639\u0627\u062a",
"billable_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
"non_billable_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631 \u06a9\u0627\u0631\u06cc",
"hourly_rate": "\u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
"income": "\u06a9\u0627\u0631\u06a9\u0631\u062f",
"working_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
"non_working_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631\u06a9\u0627\u0631\u06cc",
"hourly_rates": "\u0646\u0631\u062e\u200c\u0647\u0627\u06cc \u0633\u0627\u0639\u062a\u06cc",
"project_percentages": "\u062f\u0631\u0635\u062f \u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
"client_percentages": "\u062f\u0631\u0635\u062f \u0645\u0634\u062a\u0631\u06cc\u200c\u0647\u0627",
"tag_percentages": "\u062f\u0631\u0635\u062f \u062a\u06af\u200c\u0647\u0627",
"summary_by_user": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
"rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
"from": "\u0627\u0632",
"to": "\u062a\u0627",
"percentage": "\u062f\u0631\u0635\u062f",
"hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a",
"income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f",
"none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f",
"daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647",
"clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646",
"projects": "\u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
"tags": "\u062a\u06af\u200c\u0647\u0627",
"date": "\u062a\u0627\u0631\u06cc\u062e",
"name": "\u0646\u0627\u0645",
"total": "\u062c\u0645\u0639",
"no_data": "\u0628\u062f\u0648\u0646 \u062f\u0627\u062f\u0647",
"uncategorized_client": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc",
"uncategorized_project": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647",
"uncategorized_tag": "\u0628\u062f\u0648\u0646 \u062a\u06af",
},
}
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": "\u0627\u06cc\u0646 \u0647\u0641\u062a\u0647",
"this_month": "\u0627\u06cc\u0646 \u0645\u0627\u0647",
"this_year": "\u0627\u0645\u0633\u0627\u0644",
"half_year_first": "\u0646\u06cc\u0645\u0647 \u0627\u0648\u0644 \u0633\u0627\u0644",
"half_year_second": "\u0646\u06cc\u0645\u0647 \u062f\u0648\u0645 \u0633\u0627\u0644",
"period": "\u0628\u0627\u0632\u0647 \u062f\u0644\u062e\u0648\u0627\u0647",
},
}
CURRENCY_LABELS = {
"USD": {"en": "USD", "fa": "\u062f\u0644\u0627\u0631 \u0622\u0645\u0631\u06cc\u06a9\u0627"},
"EUR": {"en": "EUR", "fa": "\u06cc\u0648\u0631\u0648"},
"GBP": {"en": "GBP", "fa": "\u067e\u0648\u0646\u062f"},
"IRR": {"en": "IRR", "fa": "\u0631\u06cc\u0627\u0644"},
"IRT": {"en": "IRT", "fa": "\u062a\u0648\u0645\u0627\u0646"},
"AED": {"en": "AED", "fa": "\u062f\u0631\u0647\u0645"},
"TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"},
}
@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