276 lines
11 KiB
Python
276 lines
11 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",
|
|
"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": "\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",
|
|
"now": "\u062d\u0627\u0644",
|
|
"project": "\u067e\u0631\u0648\u0698\u0647",
|
|
"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",
|
|
"multiple_rates": "\u0686\u0646\u062f \u0646\u0631\u062e - \u062c\u0632\u0626\u06cc\u0627\u062a \u062f\u0631 \u06af\u0632\u0627\u0631\u0634 \u06a9\u0627\u0631\u0628\u0631",
|
|
"variable_rate": "\u0646\u0631\u062e \u0645\u062a\u063a\u06cc\u0631",
|
|
"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"},
|
|
}
|
|
|
|
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
|