Add initial Persian locale support and language handling updates
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}')])
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}, '
|
||||
|
||||
@@ -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
|
||||
@@ -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') == '❌ Нет'
|
||||
|
||||
Reference in New Issue
Block a user