feat(reports): add localized workspace reports and exports
This commit is contained in:
173
apps/reports/services/export_i18n.py
Normal file
173
apps/reports/services/export_i18n.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
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": "بازه دلخواه",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@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_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
|
||||
if not income_totals:
|
||||
return "-"
|
||||
parts = []
|
||||
for item in income_totals:
|
||||
parts.append(f"{self.format_number(item['amount'], ascii_digits=ascii_digits)} {item['currency']}")
|
||||
return " | ".join(parts)
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user