Add initial Persian locale support and language handling updates

This commit is contained in:
Ali Morshedzadeh
2026-02-09 16:53:50 +03:30
parent 056070b6a4
commit 5482e609f8
17 changed files with 1909 additions and 48 deletions
+1 -1
View File
@@ -741,7 +741,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en,ua,zh
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
+1 -1
View File
@@ -337,7 +337,7 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
}
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua']
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
# ============ Schemas ============
+39 -9
View File
@@ -20,6 +20,30 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
_LANGUAGE_META: dict[str, tuple[str, str]] = {
'ru': ('Русский', '🇷🇺'),
'en': ('English', '🇬🇧'),
'ua': ('Українська', '🇺🇦'),
'zh': ('中文', '🇨🇳'),
'fa': ('فارسی', '🇮🇷'),
}
def _normalize_language_code(value: str | None) -> str:
return (value or '').strip().lower().split('-', 1)[0]
def _get_available_language_codes() -> list[str]:
codes: list[str] = []
seen: set[str] = set()
for code in settings.get_available_languages():
normalized = _normalize_language_code(code)
if not normalized or normalized in seen:
continue
seen.add(normalized)
codes.append(normalized)
return codes
# ============ Schemas ============
@@ -212,12 +236,19 @@ async def get_service_info():
@router.get('/languages')
async def get_available_languages():
"""Get list of available languages."""
codes = _get_available_language_codes()
default_language = _normalize_language_code(getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru')
return {
'languages': [
{'code': 'ru', 'name': 'Русский', 'flag': '🇷🇺'},
{'code': 'en', 'name': 'English', 'flag': '🇬🇧'},
{
'code': code,
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
}
for code in codes
],
'default': getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
'default': default_language,
}
@@ -236,16 +267,15 @@ async def update_user_language(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's language preference."""
language = request.get('language', 'ru')
valid_languages = ['ru', 'en']
if language not in valid_languages:
requested_language = _normalize_language_code(request.get('language', 'ru'))
available_languages = _get_available_language_codes()
if requested_language not in available_languages:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid language. Supported: {", ".join(valid_languages)}',
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
)
user.language = language
user.language = requested_language
await db.commit()
await db.refresh(user)
+32 -3
View File
@@ -119,7 +119,7 @@ class EmailService:
verification_token: Verification token
verification_url: Base URL for verification (token will be appended)
username: User's name for personalization
language: Language code (ru, en, zh, ua)
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template (already wrapped in base template)
@@ -174,6 +174,16 @@ class EmailService:
'ignore': 'Якщо ви не створювали акаунт, просто проігноруйте цей лист.',
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'تایید آدرس ایمیل',
'intro': 'از ثبت‌نام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
'button': 'تایید ایمیل',
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
'ignore': 'اگر شما این حساب را ایجاد نکرده‌اید، این ایمیل را نادیده بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
@@ -236,7 +246,7 @@ class EmailService:
reset_token: Password reset token
reset_url: Base URL for password reset (token will be appended)
username: User's name for personalization
language: Language code (ru, en, zh, ua)
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template (already wrapped in base template)
@@ -291,6 +301,16 @@ class EmailService:
'warning': "Якщо ви не запитували скидання пароля, проігноруйте цей лист або зв'яжіться з підтримкою.",
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'بازنشانی رمز عبور',
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
'button': 'بازنشانی رمز عبور',
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
'warning': 'اگر شما درخواست بازنشانی رمز عبور نداده‌اید، این ایمیل را نادیده بگیرید یا با پشتیبانی تماس بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
@@ -352,7 +372,7 @@ class EmailService:
to_email: New email address
code: 6-digit verification code
username: User's name for personalization
language: Language code (ru, en, zh, ua)
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template
@@ -401,6 +421,15 @@ class EmailService:
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'کد تایید تغییر ایمیل',
'intro': 'شما درخواست تغییر ایمیل داده‌اید. برای تایید از کد زیر استفاده کنید:',
'code_label': 'کد تایید شما:',
'expires': f'این کد تا {expire_minutes} دقیقه معتبر است.',
'ignore': 'اگر شما درخواست تغییر ایمیل نداده‌اید، این ایمیل را نادیده بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
+4 -2
View File
@@ -1,7 +1,7 @@
"""
Email notification templates for different notification types.
Supports multiple languages: ru, en, zh, ua
Supports multiple languages: ru, en, zh, ua, fa
"""
from typing import Any
@@ -27,7 +27,7 @@ class EmailNotificationTemplates:
Args:
notification_type: Type of notification
language: Language code (ru, en, zh, ua)
language: Language code (ru, en, zh, ua, fa)
context: Context data for template rendering
Returns:
@@ -72,6 +72,7 @@ class EmailNotificationTemplates:
'en': 'This is an automated message. Please do not reply to this email.',
'zh': '这是一封自动发送的邮件,请勿回复。',
'ua': 'Це автоматичне повідомлення. Будь ласка, не відповідайте на цей лист.',
'fa': 'این یک پیام خودکار است. لطفاً به این ایمیل پاسخ ندهید.',
}
footer_text = footer_texts.get(language, footer_texts['ru'])
@@ -182,6 +183,7 @@ class EmailNotificationTemplates:
'en': 'Open Dashboard',
'zh': '打开控制面板',
'ua': 'Відкрити особистий кабінет',
'fa': 'باز کردن پنل کاربری',
}
text = texts.get(language, texts['en'])
+2 -2
View File
@@ -531,7 +531,7 @@ class Settings(BaseSettings):
SKIP_REFERRAL_CODE: bool = False
DEFAULT_LANGUAGE: str = 'ru'
AVAILABLE_LANGUAGES: str = 'ru,en'
AVAILABLE_LANGUAGES: str = 'ru,en,ua,zh,fa'
LANGUAGE_SELECTION_ENABLED: bool = True
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
@@ -1198,7 +1198,7 @@ class Settings(BaseSettings):
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
def get_available_languages(self) -> list[str]:
defaults = ['ru', 'en', 'ua', 'zh']
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
try:
langs = self.AVAILABLE_LANGUAGES
+17 -4
View File
@@ -28,6 +28,13 @@ from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
def _normalize_language_code(language: str | None, fallback: str = 'ru') -> str:
normalized = (language or '').strip().lower()
if '-' in normalized:
normalized = normalized.split('-', 1)[0]
return normalized or fallback
def _build_spending_stats_select():
"""
Возвращает базовый SELECT для статистики трат пользователей.
@@ -232,6 +239,7 @@ async def create_user_no_commit(
if not referral_code:
referral_code = await create_unique_referral_code(db)
normalized_language = _normalize_language_code(language)
default_group = await _get_or_create_default_promo_group(db)
promo_group_id = default_group.id
@@ -243,7 +251,7 @@ async def create_user_no_commit(
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
language=normalized_language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
@@ -277,6 +285,7 @@ async def create_user(
) -> User:
if not referral_code:
referral_code = await create_unique_referral_code(db)
normalized_language = _normalize_language_code(language)
attempts = 3
@@ -291,7 +300,7 @@ async def create_user(
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
language=normalized_language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
@@ -360,6 +369,8 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
for field, value in kwargs.items():
if field in ('first_name', 'last_name'):
value = sanitize_telegram_name(value)
if field == 'language':
value = _normalize_language_code(value)
if hasattr(user, field):
setattr(user, field, value)
@@ -1060,6 +1071,7 @@ async def create_user_by_email(
Created User object
"""
referral_code = await create_unique_referral_code(db)
normalized_language = _normalize_language_code(language)
default_group = await _get_or_create_default_promo_group(db)
user = User(
@@ -1071,7 +1083,7 @@ async def create_user_by_email(
username=None,
first_name=sanitize_telegram_name(first_name) if first_name else None,
last_name=None,
language=language,
language=normalized_language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
@@ -1283,6 +1295,7 @@ async def create_user_by_oauth(
) -> User:
"""Create a new user via OAuth provider."""
referral_code = await create_unique_referral_code(db)
normalized_language = _normalize_language_code(language)
default_group = await _get_or_create_default_promo_group(db)
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
@@ -1297,7 +1310,7 @@ async def create_user_by_oauth(
username=sanitize_telegram_name(username) if username else None,
first_name=sanitize_telegram_name(first_name) if first_name else None,
last_name=sanitize_telegram_name(last_name) if last_name else None,
language=language,
language=normalized_language,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
+7 -1
View File
@@ -416,8 +416,14 @@ def get_traffic_switch_keyboard(
buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'switch_traffic_{gb}')])
language_code = (language or 'ru').split('-')[0].lower()
buttons.append(
[InlineKeyboardButton(text='⬅️ Назад' if language == 'ru' else '⬅️ Back', callback_data='subscription_settings')]
[
InlineKeyboardButton(
text='⬅️ Назад' if language_code in {'ru', 'fa'} else '⬅️ Back',
callback_data='subscription_settings',
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
+18 -6
View File
@@ -247,6 +247,8 @@ _LANGUAGE_DISPLAY_NAMES = {
'zh-hant': '🇹🇼 中文 (繁體)',
'vi': '🇻🇳 Tiếng Việt',
'vi-vn': '🇻🇳 Tiếng Việt',
'fa': '🇮🇷 فارسی',
'fa-ir': '🇮🇷 فارسی',
}
@@ -1789,6 +1791,8 @@ def get_add_traffic_keyboard(
from app.utils.pricing_utils import get_remaining_months
texts = get_texts(language)
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
use_russian_fallback = language_code in {'ru', 'fa'}
months_multiplier = 1
period_text = ''
@@ -1826,17 +1830,20 @@ def get_add_traffic_keyboard(
total_discount = discount_per_month * months_multiplier
if gb == 0:
if language == 'ru':
if use_russian_fallback:
text = f'♾️ Безлимитный трафик - {total_price // 100}{period_text}'
else:
text = f'♾️ Unlimited traffic - {total_price // 100}{period_text}'
elif language == 'ru':
elif use_russian_fallback:
text = f'📊 +{gb} ГБ трафика - {total_price // 100}{period_text}'
else:
text = f'📊 +{gb} GB traffic - {total_price // 100}{period_text}'
if discount_percent > 0 and total_discount > 0:
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
if use_russian_fallback:
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
else:
text += f' (discount {discount_percent}%: -{total_discount // 100}₽)'
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
@@ -1861,6 +1868,8 @@ def get_add_traffic_keyboard_from_tariff(
discount_percent: Процент скидки
"""
texts = get_texts(language)
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
use_russian_fallback = language_code in {'ru', 'fa'}
if not packages:
return InlineKeyboardMarkup(
@@ -1888,15 +1897,18 @@ def get_add_traffic_keyboard_from_tariff(
discount_percent,
)
period_text = ' /мес' if language == 'ru' else ' /mo'
period_text = ' /мес' if use_russian_fallback else ' /mo'
if language == 'ru':
if use_russian_fallback:
text = f'📊 +{gb} ГБ трафика - {discounted_price // 100}{period_text}'
else:
text = f'📊 +{gb} GB traffic - {discounted_price // 100}{period_text}'
if discount_percent > 0 and discount_value > 0:
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
if use_russian_fallback:
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
else:
text += f' (discount {discount_percent}%: -{discount_value // 100}₽)'
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
+1 -1
View File
@@ -231,7 +231,7 @@ def ensure_locale_templates() -> None:
_copy_locale(template, destination / template.name)
return
for locale_code in ('ru', 'en'):
for locale_code in ('ru', 'en', 'fa'):
source_path = _DEFAULT_LOCALES_DIR / f'{locale_code}.json'
target_path = destination / f'{locale_code}.json'
File diff suppressed because it is too large Load Diff
+12
View File
@@ -35,6 +35,18 @@ _DYNAMIC_LANGUAGE_CONFIGS = {
'Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n'
),
},
'fa': {
'traffic_pattern': '📊 {size} گیگابایت - {price}',
'unlimited_pattern': '📊 نامحدود - {price}',
'support_info': (
'\n🛟 <b>پشتیبانی</b>\n\n'
'برای هرگونه سؤال به پشتیبانی پیام دهید:\n\n'
'👤 {support_username}\n\n'
'• 🎫 ایجاد تیکت\n'
'• 📋 تیکت‌های من\n'
'• 💬 تماس مستقیم\n'
),
},
'en': {
'traffic_pattern': '📊 {size} GB - {price}',
'unlimited_pattern': '📊 Unlimited - {price}',
+18 -10
View File
@@ -87,7 +87,8 @@ def format_time_ago(dt: datetime | str, language: str = 'ru') -> str:
def format_days_declension(days: int, language: str = 'ru') -> str:
if language != 'ru':
language_code = (language or 'ru').split('-')[0].lower()
if language_code not in {'ru', 'fa'}:
return f'{days} day{"s" if days != 1 else ""}'
if days % 10 == 1 and days % 100 != 11:
@@ -180,42 +181,49 @@ def format_subscription_status(is_active: bool, is_trial: bool, end_date: dateti
except (ValueError, AttributeError):
end_date = datetime.now()
language_code = (language or 'ru').split('-')[0].lower()
use_russian_fallback = language_code in {'ru', 'fa'}
if not is_active:
return '❌ Неактивна' if language == 'ru' else '❌ Inactive'
return '❌ Неактивна' if use_russian_fallback else '❌ Inactive'
if is_trial:
status = '🎁 Тестовая' if language == 'ru' else '🎁 Trial'
status = '🎁 Тестовая' if use_russian_fallback else '🎁 Trial'
else:
status = '✅ Активна' if language == 'ru' else '✅ Active'
status = '✅ Активна' if use_russian_fallback else '✅ Active'
now = datetime.utcnow()
if end_date > now:
days_left = (end_date - now).days
if days_left > 0:
status += f' ({days_left} дн.)' if language == 'ru' else f' ({days_left} days)'
status += f' ({days_left} дн.)' if use_russian_fallback else f' ({days_left} days)'
else:
hours_left = (end_date - now).seconds // 3600
status += f' ({hours_left} ч.)' if language == 'ru' else f' ({hours_left} hrs)'
status += f' ({hours_left} ч.)' if use_russian_fallback else f' ({hours_left} hrs)'
else:
status = '⏰ Истекла' if language == 'ru' else '⏰ Expired'
status = '⏰ Истекла' if use_russian_fallback else '⏰ Expired'
return status
def format_traffic_usage(used_gb: float, limit_gb: int, language: str = 'ru') -> str:
language_code = (language or 'ru').split('-')[0].lower()
use_russian_fallback = language_code in {'ru', 'fa'}
if limit_gb == 0:
if language == 'ru':
if use_russian_fallback:
return f'{used_gb:.1f} ГБ / ∞'
return f'{used_gb:.1f} GB / ∞'
percentage = (used_gb / limit_gb) * 100 if limit_gb > 0 else 0
if language == 'ru':
if use_russian_fallback:
return f'{used_gb:.1f} ГБ / {limit_gb} ГБ ({percentage:.1f}%)'
return f'{used_gb:.1f} GB / {limit_gb} GB ({percentage:.1f}%)'
def format_boolean(value: bool, language: str = 'ru') -> str:
if language == 'ru':
language_code = (language or 'ru').split('-')[0].lower()
if language_code in {'ru', 'fa'}:
return '✅ Да' if value else '❌ Нет'
return '✅ Yes' if value else '❌ No'
+2 -1
View File
@@ -307,7 +307,8 @@ def _pluralize_days_ru(n: int) -> str:
def format_period_description(days: int, language: str = 'ru') -> str:
if language == 'ru':
language_code = (language or 'ru').split('-')[0].lower()
if language_code in {'ru', 'fa'}:
if days == 30:
return '1 месяц'
if days == 60:
+7 -7
View File
@@ -4054,7 +4054,7 @@ async def activate_subscription_trial_endpoint(
language_code = _normalize_language_code(user)
charged_amount_label = settings.format_price(charged_amount) if charged_amount > 0 else None
if language_code == 'ru':
if language_code in {'ru', 'fa'}:
if duration_days:
message = f'Триал активирован на {duration_days} дн. Приятного пользования!'
else:
@@ -4065,7 +4065,7 @@ async def activate_subscription_trial_endpoint(
message = 'Trial activated successfully. Enjoy!'
if charged_amount_label:
if language_code == 'ru':
if language_code in {'ru', 'fa'}:
message = f'{message}\n\n💳 С вашего баланса списано {charged_amount_label}.'
else:
message = f'{message}\n\n💳 {charged_amount_label} has been deducted from your balance.'
@@ -4476,7 +4476,7 @@ def _normalize_language_code(user: User | None) -> str:
def _build_renewal_status_message(user: User | None) -> str:
language_code = _normalize_language_code(user)
if language_code == 'ru':
if language_code in {'ru', 'fa'}:
return 'Стоимость указана с учётом ваших текущих серверов, трафика и устройств.'
return 'Prices already include your current servers, traffic, and devices.'
@@ -4493,7 +4493,7 @@ def _build_promo_offer_payload(user: User | None) -> dict[str, Any] | None:
payload['expires_at'] = expires_at
language_code = _normalize_language_code(user)
if language_code == 'ru':
if language_code in {'ru', 'fa'}:
payload['message'] = 'Дополнительная скидка применяется автоматически.'
else:
payload['message'] = 'Extra discount is applied automatically.'
@@ -4527,7 +4527,7 @@ def _build_renewal_success_message(
amount_label = settings.format_price(max(0, charged_amount))
date_label = format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M') if subscription.end_date else ''
if language_code == 'ru':
if language_code in {'ru', 'fa'}:
if charged_amount > 0:
message = (
f'Подписка продлена до {date_label}. ' if date_label else 'Подписка продлена. '
@@ -4543,7 +4543,7 @@ def _build_renewal_success_message(
if promo_discount_value > 0:
discount_label = settings.format_price(promo_discount_value)
if language_code == 'ru':
if language_code in {'ru', 'fa'}:
message += f' Применена дополнительная скидка {discount_label}.'
else:
message += f' Promo discount applied: {discount_label}.'
@@ -4560,7 +4560,7 @@ def _build_renewal_pending_message(
amount_label = settings.format_price(max(0, missing_amount))
method_title = _format_payment_method_title(method)
if language_code == 'ru':
if language_code in {'ru', 'fa'}:
if method_title:
return (
f'Недостаточно средств на балансе. Доплатите {amount_label} через {method_title}, '
+16
View File
@@ -0,0 +1,16 @@
from app.config import settings
def test_available_languages_default_contains_fa(monkeypatch):
monkeypatch.setattr(settings, 'AVAILABLE_LANGUAGES', '', raising=False)
languages = settings.get_available_languages()
assert 'fa' in languages
def test_available_languages_normalizes_and_deduplicates(monkeypatch):
monkeypatch.setattr(settings, 'AVAILABLE_LANGUAGES', 'ru,en,fa,FA,fa-IR', raising=False)
languages = settings.get_available_languages()
assert languages[0] == 'ru'
assert 'en' in languages
assert 'fa' in languages
assert 'FA' not in languages
+12
View File
@@ -31,6 +31,12 @@ def test_format_days_declension_handles_russian_rules() -> None:
assert formatters.format_days_declension(10) == '10 дней'
def test_format_days_declension_uses_russian_fallback_for_fa() -> None:
"""Для fa используем fallback на русские формы до полной локализации."""
assert formatters.format_days_declension(1, language='fa') == '1 день'
assert formatters.format_days_declension(3, language='fa') == '3 дня'
def test_format_duration_switches_units() -> None:
"""В зависимости от длины интервала выбирается подходящая единица измерения."""
assert formatters.format_duration(45) == '45 сек.'
@@ -102,3 +108,9 @@ def test_format_boolean_localises_output() -> None:
"""Булевые значения отображаются локализованными словами."""
assert formatters.format_boolean(True, language='ru') == '✅ Да'
assert formatters.format_boolean(False, language='en') == '❌ No'
def test_format_boolean_uses_russian_fallback_for_fa() -> None:
"""Для fa булевы значения пока используют базовый ru fallback."""
assert formatters.format_boolean(True, language='fa') == '✅ Да'
assert formatters.format_boolean(False, language='fa') == '❌ Нет'