feat: add runtime locale overrides
This commit is contained in:
@@ -30,7 +30,7 @@ def build_core_services(
|
||||
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
||||
email_auth_service = EmailAuthService(settings)
|
||||
email_auth_service = EmailAuthService(settings, i18n)
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
|
||||
@@ -19,6 +19,7 @@ from bot.app.web.admin_api_impl import (
|
||||
sync as _sync,
|
||||
tariffs as _tariffs,
|
||||
themes as _themes,
|
||||
translations as _translations,
|
||||
users as _users,
|
||||
)
|
||||
|
||||
@@ -38,6 +39,7 @@ _MODULES = (
|
||||
_settings,
|
||||
_tariffs,
|
||||
_themes,
|
||||
_translations,
|
||||
_panel,
|
||||
_routes,
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ from config.tariffs_config import TariffsConfig
|
||||
from db.dal import (
|
||||
ad_dal,
|
||||
app_settings_dal,
|
||||
locale_overrides_dal,
|
||||
message_log_dal,
|
||||
panel_sync_dal,
|
||||
payment_dal,
|
||||
|
||||
@@ -66,6 +66,8 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
|
||||
router.add_get("/api/admin/settings", admin_settings_get_route)
|
||||
router.add_patch("/api/admin/settings", admin_settings_patch_route)
|
||||
router.add_get("/api/admin/translations", admin_translations_get_route)
|
||||
router.add_patch("/api/admin/translations", admin_translations_patch_route)
|
||||
|
||||
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
|
||||
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, locale_language_options, resolve_locale_key
|
||||
from bot.services.locale_override_service import (
|
||||
LOCALE_OVERRIDES_PATH,
|
||||
audience_for_locale_key,
|
||||
group_id_for_locale_key,
|
||||
locale_group_catalog,
|
||||
load_locale_overrides,
|
||||
update_locale_overrides,
|
||||
)
|
||||
|
||||
def _locale_languages(
|
||||
i18n: JsonI18n,
|
||||
overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
base_languages = set((i18n.base_locales_data or {}).keys())
|
||||
override_languages = {str(entry.get("lang") or "") for entry in overrides or []}
|
||||
override_languages.update((i18n.locale_overrides or {}).keys())
|
||||
return locale_language_options(
|
||||
base_languages | override_languages,
|
||||
base_languages=base_languages,
|
||||
)
|
||||
|
||||
|
||||
def _locale_override_meta_map(overrides: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict]:
|
||||
result: Dict[Tuple[str, str], Dict] = {}
|
||||
for entry in overrides:
|
||||
lang = str(entry.get("lang") or "")
|
||||
raw_key = str(entry.get("key") or "")
|
||||
key = resolve_locale_key(raw_key)
|
||||
if lang and key:
|
||||
if raw_key != key and (lang, key) in result:
|
||||
continue
|
||||
result[(lang, key)] = entry
|
||||
return result
|
||||
|
||||
|
||||
def _admin_translations_payload(
|
||||
i18n: JsonI18n,
|
||||
overrides: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
base_data = i18n.base_locales_data or i18n.locales_data or {}
|
||||
effective_data = i18n.locales_data or {}
|
||||
override_meta = _locale_override_meta_map(overrides)
|
||||
language_items = _locale_languages(i18n, overrides)
|
||||
languages = [item["code"] for item in language_items]
|
||||
all_keys = sorted(
|
||||
{key for messages in base_data.values() for key in messages.keys()}
|
||||
| {key for _, key in override_meta.keys()}
|
||||
)
|
||||
|
||||
groups_by_id = {
|
||||
group["id"]: {
|
||||
**group,
|
||||
"items": [],
|
||||
}
|
||||
for group in locale_group_catalog()
|
||||
}
|
||||
|
||||
for key in all_keys:
|
||||
values: Dict[str, Dict[str, Any]] = {}
|
||||
for lang in languages:
|
||||
meta = override_meta.get((lang, key))
|
||||
fallback_base = base_data.get(i18n.default_lang, {}).get(key, "")
|
||||
values[lang] = {
|
||||
"base": base_data.get(lang, {}).get(key, ""),
|
||||
"fallback": fallback_base,
|
||||
"effective": effective_data.get(lang, {}).get(key, ""),
|
||||
"override": meta.get("value") if meta else "",
|
||||
"overridden": bool(meta),
|
||||
"updated_at": meta.get("updated_at") if meta else None,
|
||||
"updated_by": meta.get("updated_by") if meta else None,
|
||||
}
|
||||
group_id = group_id_for_locale_key(key)
|
||||
groups_by_id.setdefault(
|
||||
group_id,
|
||||
{"id": group_id, "title": group_id, "description": "", "items": []},
|
||||
)
|
||||
groups_by_id[group_id]["items"].append(
|
||||
{
|
||||
"key": key,
|
||||
"audience": audience_for_locale_key(key),
|
||||
"values": values,
|
||||
}
|
||||
)
|
||||
|
||||
groups = [group for group in groups_by_id.values() if group["items"]]
|
||||
return {
|
||||
"languages": language_items,
|
||||
"groups": groups,
|
||||
"path": str(LOCALE_OVERRIDES_PATH),
|
||||
"override_count": len(overrides),
|
||||
}
|
||||
|
||||
|
||||
async def admin_translations_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||
if i18n is None:
|
||||
return _error(503, "i18n_unavailable")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
await load_locale_overrides(i18n, async_session_factory)
|
||||
async with async_session_factory() as session:
|
||||
overrides = await locale_overrides_dal.get_overrides_with_meta(session)
|
||||
|
||||
return _ok(_admin_translations_payload(i18n, overrides))
|
||||
|
||||
|
||||
async def admin_translations_patch_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||
if i18n is None:
|
||||
return _error(503, "i18n_unavailable")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
payload = await _read_json(request)
|
||||
updates = payload.get("updates") or {}
|
||||
deletes = payload.get("deletes") or []
|
||||
if not isinstance(updates, dict):
|
||||
return _error(400, "invalid_updates")
|
||||
if not isinstance(deletes, list):
|
||||
return _error(400, "invalid_deletes")
|
||||
|
||||
result = await update_locale_overrides(
|
||||
i18n,
|
||||
async_session_factory,
|
||||
updates=updates,
|
||||
deletes=deletes,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
|
||||
status=400,
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"applied": result.get("applied", 0),
|
||||
"reverted": result.get("reverted", 0),
|
||||
"file_written": result.get("file_written", False),
|
||||
}
|
||||
)
|
||||
@@ -488,6 +488,11 @@ async def account_language_route(request: web.Request) -> web.Response:
|
||||
return validation_error
|
||||
|
||||
language = _normalize_language(str(language_payload.language or ""))
|
||||
i18n = request.app.get("i18n")
|
||||
if i18n and hasattr(i18n, "reload_overrides_from_file"):
|
||||
i18n.reload_overrides_from_file()
|
||||
if i18n and language not in getattr(i18n, "locales_data", {}):
|
||||
return _json_error(400, "unsupported_language", "Unsupported language")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
|
||||
@@ -21,7 +21,7 @@ def create_subscription_webapp_application(
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
app["email_auth_service"] = EmailAuthService(settings)
|
||||
app["email_auth_service"] = EmailAuthService(settings, app["i18n"])
|
||||
app["webapp_logo_cache"] = None
|
||||
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
||||
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
|
||||
|
||||
@@ -9,6 +9,7 @@ from config.webapp_themes_config import (
|
||||
public_theme_payload,
|
||||
public_themes_catalog_payload,
|
||||
)
|
||||
from bot.middlewares.i18n import locale_language_options
|
||||
|
||||
_TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {}
|
||||
_BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {}
|
||||
@@ -1157,7 +1158,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
||||
preview_key = ""
|
||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||
i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp")
|
||||
if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"):
|
||||
i18n_instance.reload_overrides_from_file()
|
||||
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
||||
base_locales_data = getattr(i18n_instance, "base_locales_data", {}) if i18n_instance else {}
|
||||
return {
|
||||
"config": {
|
||||
"title": settings.WEBAPP_TITLE,
|
||||
@@ -1188,6 +1192,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
||||
"userAgreementUrl": cached["user_agreement_url"],
|
||||
"currency": cached["currency"],
|
||||
"language": cached["language"],
|
||||
"languages": locale_language_options(
|
||||
locales_data.keys(),
|
||||
base_languages=base_locales_data.keys(),
|
||||
),
|
||||
"emailAuthEnabled": cached["email_auth_enabled"],
|
||||
"appVersion": _resolve_app_version(),
|
||||
"appRepositoryUrl": APP_REPOSITORY_URL,
|
||||
@@ -1204,6 +1212,8 @@ async def bootstrap_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def i18n_route(request: web.Request) -> web.Response:
|
||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||
if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"):
|
||||
i18n_instance.reload_overrides_from_file()
|
||||
scope = _normalize_i18n_scope(request.query.get("scope") or "webapp")
|
||||
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
||||
response = web.json_response(
|
||||
|
||||
@@ -1202,11 +1202,9 @@ def _apply_telegram_profile_to_user(
|
||||
telegram_user: Dict[str, Any],
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
language_code = (
|
||||
language_code = _normalize_language(
|
||||
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = user.language_code or settings.DEFAULT_LANGUAGE
|
||||
|
||||
user.telegram_id = int(telegram_user["id"])
|
||||
user.username = sanitize_username(telegram_user.get("username"))
|
||||
@@ -1252,13 +1250,11 @@ async def _link_telegram_to_user(
|
||||
return merged_user
|
||||
|
||||
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||
language_code = (
|
||||
language_code = _normalize_language(
|
||||
telegram_user.get("language_code")
|
||||
or current_user.language_code
|
||||
or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = current_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
target_user, _ = await user_dal.create_user(
|
||||
session,
|
||||
{
|
||||
@@ -1404,9 +1400,9 @@ async def _ensure_user_from_telegram(
|
||||
referral_param: Optional[str] = None,
|
||||
) -> User:
|
||||
user_id = int(telegram_user["id"])
|
||||
language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = settings.DEFAULT_LANGUAGE
|
||||
language_code = _normalize_language(
|
||||
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
update_data = {
|
||||
"telegram_id": user_id,
|
||||
|
||||
@@ -4,6 +4,10 @@ from ._runtime import * # noqa: F403,F405
|
||||
from bot.app.web.webapp.cache_helpers import (
|
||||
invalidate_webapp_user_caches as _invalidate_user_payload_caches,
|
||||
)
|
||||
from bot.middlewares.i18n import (
|
||||
is_valid_locale_language_code,
|
||||
normalize_locale_language_code,
|
||||
)
|
||||
|
||||
|
||||
async def _read_json(request: web.Request) -> Dict[str, Any]:
|
||||
@@ -73,8 +77,8 @@ def _validate_model_payload(
|
||||
|
||||
|
||||
def _normalize_language(lang: Optional[str]) -> str:
|
||||
value = (lang or "ru").split("-")[0].lower()
|
||||
return value if value in {"ru", "en"} else "ru"
|
||||
value = normalize_locale_language_code(lang, prefer_known_base=False)
|
||||
return value if is_valid_locale_language_code(value) else "ru"
|
||||
|
||||
|
||||
def _format_remaining(seconds: int, lang: str) -> str:
|
||||
|
||||
@@ -19,7 +19,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get(
|
||||
(
|
||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||
"appearance|settings|support}"
|
||||
"appearance|settings|translations|support}"
|
||||
),
|
||||
index_route,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_language_selection_keyboard,
|
||||
get_main_menu_inline_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.middlewares.i18n import JsonI18n, normalize_locale_language_code
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
@@ -913,7 +913,12 @@ async def select_language_callback_handler(
|
||||
|
||||
try:
|
||||
lang_payload = callback.data.split("_", 2)[2]
|
||||
lang_code, _, return_target = lang_payload.partition(":")
|
||||
raw_lang_code, _, return_target = lang_payload.partition(":")
|
||||
lang_code = normalize_locale_language_code(
|
||||
raw_lang_code,
|
||||
set(i18n.locales_data.keys()),
|
||||
prefer_known_base=True,
|
||||
)
|
||||
except IndexError:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
@@ -921,6 +926,13 @@ async def select_language_callback_handler(
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
if lang_code not in i18n.locales_data:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Unsupported language.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
try:
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
|
||||
from bot.middlewares.i18n import locale_language_options
|
||||
from bot.utils.install_links import bot_install_guide_url
|
||||
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
||||
from config.settings import Settings
|
||||
@@ -246,14 +247,18 @@ def get_language_selection_keyboard(
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs)
|
||||
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=f"🇬🇧 English {'✅' if current_lang == 'en' else ''}",
|
||||
callback_data=f"set_lang_en{callback_suffix}",
|
||||
)
|
||||
builder.button(
|
||||
text=f"🇷🇺 Русский {'✅' if current_lang == 'ru' else ''}",
|
||||
callback_data=f"set_lang_ru{callback_suffix}",
|
||||
)
|
||||
if hasattr(i18n_instance, "language_options"):
|
||||
languages = i18n_instance.language_options()
|
||||
else:
|
||||
locales_data = getattr(i18n_instance, "locales_data", {}) or {"ru": {}, "en": {}}
|
||||
languages = locale_language_options(locales_data.keys(), base_languages=locales_data.keys())
|
||||
for language in languages:
|
||||
lang_code = language["code"]
|
||||
checked = " ✅" if current_lang == lang_code else ""
|
||||
builder.button(
|
||||
text=f"{language['flag']} {language['label']}{checked}",
|
||||
callback_data=f"set_lang_{lang_code}{callback_suffix}",
|
||||
)
|
||||
builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -12,6 +12,7 @@ from bot.app.web.web_server import build_and_start_web_app
|
||||
from bot.infra.redis import close_redis
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.routers import build_root_router
|
||||
from bot.services.locale_override_service import load_locale_overrides
|
||||
from bot.services.settings_override_service import load_overrides_from_db
|
||||
from bot.utils.message_queue import init_queue_manager
|
||||
from config.settings import Settings
|
||||
@@ -269,6 +270,7 @@ async def run_bot(settings_param: Settings):
|
||||
await load_overrides_from_db(settings_param, local_async_session_factory)
|
||||
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
|
||||
i18n_instance = extra["i18n_instance"]
|
||||
await load_locale_overrides(i18n_instance, local_async_session_factory)
|
||||
|
||||
# Get bot username for YooKassa default return URL if needed
|
||||
actual_bot_username = "your_bot_username"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Update, User
|
||||
@@ -10,14 +13,309 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
LocaleOverrides = Dict[str, Dict[str, str]]
|
||||
|
||||
_LOCALE_LANGUAGE_CODE_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
|
||||
LANGUAGE_LABELS: Dict[str, str] = {
|
||||
"ru": "Русский",
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"es": "Español",
|
||||
"fr": "Français",
|
||||
"pt-br": "Português (BR)",
|
||||
"tr": "Türkçe",
|
||||
"uk": "Українська",
|
||||
}
|
||||
LANGUAGE_FLAGS: Dict[str, str] = {
|
||||
"ru": "🇷🇺",
|
||||
"en": "🇬🇧",
|
||||
"de": "🇩🇪",
|
||||
"es": "🇪🇸",
|
||||
"fr": "🇫🇷",
|
||||
"pt-br": "🇧🇷",
|
||||
"tr": "🇹🇷",
|
||||
"uk": "🇺🇦",
|
||||
}
|
||||
DEFAULT_LANGUAGE_ORDER = ("ru", "en")
|
||||
LOCALE_KEY_ALIASES: Dict[str, str] = {
|
||||
"admin_apply": "wa_apply",
|
||||
"admin_ads_col_status": "admin_status",
|
||||
"admin_ad_label_source": "admin_ads_col_source",
|
||||
"admin_back": "wa_back",
|
||||
"admin_btn_refresh": "admin_refresh",
|
||||
"admin_btn_save": "admin_save",
|
||||
"admin_btn_saving": "admin_saving",
|
||||
"admin_close": "wa_close",
|
||||
"admin_copied": "wa_copied",
|
||||
"admin_copy": "wa_copy",
|
||||
"admin_csv_amount": "admin_amount",
|
||||
"admin_csv_description": "admin_description",
|
||||
"admin_csv_payment_id": "admin_id",
|
||||
"admin_csv_status": "admin_status",
|
||||
"admin_link_copied": "wa_link_copied",
|
||||
"admin_next": "wa_next",
|
||||
"admin_payment_detail_copied": "wa_copied",
|
||||
"admin_payment_detail_provider": "admin_provider",
|
||||
"admin_payment_detail_provider_section": "admin_provider",
|
||||
"admin_payment_detail_user_section": "admin_user",
|
||||
"admin_payments_col_user_id": "admin_id",
|
||||
"admin_promo_col_code": "admin_promo_csv_code",
|
||||
"admin_promo_col_status": "admin_status",
|
||||
"admin_promo_csv_is_active": "admin_badge_active",
|
||||
"admin_promo_csv_status": "admin_status",
|
||||
"admin_promo_label_code": "admin_promo_csv_code",
|
||||
"admin_promo_unlimited_validity": "admin_promo_unlimited",
|
||||
"admin_stats_revenue_custom_range_apply": "wa_apply",
|
||||
"admin_stats_revenue_tooltip_amount": "admin_amount",
|
||||
"admin_stats_sync_status": "admin_status",
|
||||
"admin_status_active": "admin_badge_active",
|
||||
"admin_support_category": "wa_support_category",
|
||||
"admin_support_category_account": "wa_support_category_account",
|
||||
"admin_support_category_billing": "wa_support_category_billing",
|
||||
"admin_support_category_other": "wa_support_category_other",
|
||||
"admin_support_category_technical": "wa_support_category_technical",
|
||||
"admin_support_close_ticket": "wa_close",
|
||||
"admin_support_empty": "wa_support_empty",
|
||||
"admin_support_filter_active": "wa_support_filter_active",
|
||||
"admin_support_filter_all": "wa_support_filter_all",
|
||||
"admin_support_internal_note": "wa_support_internal_note",
|
||||
"admin_support_no_messages": "wa_support_no_messages",
|
||||
"admin_support_priority": "wa_support_priority",
|
||||
"admin_support_priority_high": "wa_support_priority_high",
|
||||
"admin_support_priority_low": "wa_support_priority_low",
|
||||
"admin_support_priority_normal": "wa_support_priority_normal",
|
||||
"admin_support_priority_urgent": "wa_support_priority_urgent",
|
||||
"admin_support_role_system": "wa_support_role_system",
|
||||
"admin_support_role_user": "admin_user",
|
||||
"admin_support_search": "admin_search",
|
||||
"admin_support_status": "admin_status",
|
||||
"admin_support_status_awaiting_admin": "wa_support_status_awaiting_admin",
|
||||
"admin_support_status_awaiting_user": "wa_support_status_awaiting_user",
|
||||
"admin_support_status_closed": "wa_support_status_closed",
|
||||
"admin_support_status_open": "wa_support_status_open",
|
||||
"admin_support_status_resolved": "wa_support_status_resolved",
|
||||
"admin_support_ticket_number": "wa_support_ticket_number",
|
||||
"admin_support_user_context": "admin_user",
|
||||
"admin_tariffs_legacy_traffic_packages": "admin_tariff_traffic_packages",
|
||||
"admin_tariffs_stat_enabled": "admin_enabled",
|
||||
"admin_user_btn_cancel": "wa_cancel",
|
||||
"admin_user_history_until": "wa_until_date",
|
||||
"admin_user_label_provider": "admin_provider",
|
||||
"admin_user_short": "admin_user",
|
||||
"admin_user_stats_total_label": "admin_total",
|
||||
"back_to_autopay_method_choice_button": "back_to_main_menu_button",
|
||||
"back_to_payment_methods_button": "back_to_main_menu_button",
|
||||
"cancel_broadcast_button": "cancel_button",
|
||||
"csv_no": "no_button",
|
||||
"csv_yes": "yes_button",
|
||||
"user_premium_override_status_unlimited": "user_regular_override_status_unlimited",
|
||||
"user_regular_override_save": "admin_save",
|
||||
"wa_devices_disconnect_title": "wa_devices_disconnect",
|
||||
"wa_install_link_copied": "wa_link_copied",
|
||||
"wa_link_email_modal_title": "wa_settings_link_email_action",
|
||||
}
|
||||
|
||||
|
||||
def resolve_locale_key(key: object) -> str:
|
||||
value = str(key or "").strip()
|
||||
seen: Set[str] = set()
|
||||
while value in LOCALE_KEY_ALIASES and value not in seen:
|
||||
seen.add(value)
|
||||
value = LOCALE_KEY_ALIASES[value]
|
||||
return value
|
||||
|
||||
|
||||
def is_valid_locale_language_code(value: str) -> bool:
|
||||
return 2 <= len(value) <= 16 and bool(_LOCALE_LANGUAGE_CODE_RE.fullmatch(value))
|
||||
|
||||
|
||||
def normalize_locale_language_code(
|
||||
raw: object,
|
||||
valid_languages: Optional[Set[str]] = None,
|
||||
*,
|
||||
prefer_known_base: bool = True,
|
||||
) -> str:
|
||||
value = str(raw or "").strip().lower().replace("_", "-")
|
||||
if not value:
|
||||
return ""
|
||||
if prefer_known_base and valid_languages and value not in valid_languages:
|
||||
base = value.split("-", 1)[0]
|
||||
if base in valid_languages:
|
||||
return base
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_language_code(raw: object, valid_languages: Optional[Set[str]] = None) -> str:
|
||||
return normalize_locale_language_code(raw, valid_languages)
|
||||
|
||||
|
||||
def locale_language_label(code: object) -> str:
|
||||
value = normalize_locale_language_code(code, prefer_known_base=False)
|
||||
return LANGUAGE_LABELS.get(value, value.upper())
|
||||
|
||||
|
||||
def locale_language_flag(code: object) -> str:
|
||||
value = normalize_locale_language_code(code, prefer_known_base=False)
|
||||
return LANGUAGE_FLAGS.get(value, "🏳️")
|
||||
|
||||
|
||||
def sort_locale_language_codes(codes: Iterable[object]) -> List[str]:
|
||||
normalized = {
|
||||
normalize_locale_language_code(code, prefer_known_base=False)
|
||||
for code in codes
|
||||
}
|
||||
normalized = {code for code in normalized if code and is_valid_locale_language_code(code)}
|
||||
preferred = [code for code in DEFAULT_LANGUAGE_ORDER if code in normalized]
|
||||
rest = sorted(code for code in normalized if code not in DEFAULT_LANGUAGE_ORDER)
|
||||
return [*preferred, *rest]
|
||||
|
||||
|
||||
def locale_language_options(
|
||||
codes: Iterable[object],
|
||||
*,
|
||||
base_languages: Iterable[object] = (),
|
||||
) -> List[Dict[str, Any]]:
|
||||
base_set = set(sort_locale_language_codes(base_languages))
|
||||
return [
|
||||
{
|
||||
"code": code,
|
||||
"label": locale_language_label(code),
|
||||
"flag": locale_language_flag(code),
|
||||
"base": code in base_set,
|
||||
}
|
||||
for code in sort_locale_language_codes(codes)
|
||||
]
|
||||
|
||||
|
||||
def _valid_locale_keys_by_language(
|
||||
locales_data: Dict[str, Dict[str, str]],
|
||||
) -> Dict[str, Set[str]]:
|
||||
return {
|
||||
lang: {str(key) for key in messages.keys()}
|
||||
for lang, messages in locales_data.items()
|
||||
if isinstance(messages, dict)
|
||||
}
|
||||
|
||||
|
||||
def normalize_locale_overrides_payload(
|
||||
payload: object,
|
||||
*,
|
||||
valid_languages: Optional[Iterable[str]] = None,
|
||||
valid_keys_by_language: Optional[Dict[str, Set[str]]] = None,
|
||||
allow_extra_languages: bool = False,
|
||||
key_aliases: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[LocaleOverrides, Dict[str, str]]:
|
||||
"""Normalize a user/admin supplied locale override JSON payload.
|
||||
|
||||
The canonical shape is ``{"ru": {"welcome": "..."}, "en": {...}}``.
|
||||
For convenience, files may also wrap it as ``{"overrides": {...}}`` or
|
||||
``{"locales": {...}}``.
|
||||
"""
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return {}, {"_payload": "invalid_payload"}
|
||||
|
||||
raw_payload = payload
|
||||
for wrapper_key in ("overrides", "locales"):
|
||||
wrapped = raw_payload.get(wrapper_key)
|
||||
if isinstance(wrapped, dict):
|
||||
raw_payload = wrapped
|
||||
break
|
||||
|
||||
valid_lang_set = {str(lang).lower() for lang in valid_languages or []}
|
||||
aliases = key_aliases or LOCALE_KEY_ALIASES
|
||||
|
||||
def resolve_payload_key(raw_key: str) -> str:
|
||||
value = raw_key
|
||||
seen: Set[str] = set()
|
||||
while value in aliases and value not in seen:
|
||||
seen.add(value)
|
||||
value = aliases[value]
|
||||
return value
|
||||
|
||||
all_valid_keys: Set[str] = set()
|
||||
if valid_keys_by_language:
|
||||
for keys in valid_keys_by_language.values():
|
||||
all_valid_keys.update(str(key) for key in keys)
|
||||
|
||||
overrides: LocaleOverrides = {}
|
||||
errors: Dict[str, str] = {}
|
||||
|
||||
for raw_lang, raw_messages in raw_payload.items():
|
||||
lang = normalize_locale_language_code(
|
||||
raw_lang,
|
||||
valid_lang_set or None,
|
||||
prefer_known_base=not allow_extra_languages,
|
||||
)
|
||||
error_key = str(raw_lang or "_language")
|
||||
if not lang:
|
||||
errors[error_key] = "invalid_language"
|
||||
continue
|
||||
if valid_lang_set and lang not in valid_lang_set:
|
||||
if not allow_extra_languages:
|
||||
errors[error_key] = "unknown_language"
|
||||
continue
|
||||
if not is_valid_locale_language_code(lang):
|
||||
errors[error_key] = "invalid_language"
|
||||
continue
|
||||
elif allow_extra_languages and not is_valid_locale_language_code(lang):
|
||||
errors[error_key] = "invalid_language"
|
||||
continue
|
||||
if not isinstance(raw_messages, dict):
|
||||
errors[lang] = "invalid_language_bucket"
|
||||
continue
|
||||
|
||||
lang_keys = valid_keys_by_language.get(lang, set()) if valid_keys_by_language else set()
|
||||
bucket: Dict[str, str] = {}
|
||||
for raw_key, raw_value in raw_messages.items():
|
||||
raw_key_text = str(raw_key or "").strip()
|
||||
key = resolve_payload_key(raw_key_text)
|
||||
item_error_key = f"{lang}.{raw_key_text or '_key'}"
|
||||
if not raw_key_text or not key:
|
||||
errors[item_error_key] = "invalid_key"
|
||||
continue
|
||||
if all_valid_keys and key not in all_valid_keys and key not in lang_keys:
|
||||
errors[item_error_key] = "unknown_key"
|
||||
continue
|
||||
if raw_value is None:
|
||||
continue
|
||||
if not isinstance(raw_value, str):
|
||||
errors[item_error_key] = "invalid_value"
|
||||
continue
|
||||
if len(raw_value) > 20000:
|
||||
errors[item_error_key] = "value_too_long"
|
||||
continue
|
||||
if raw_key_text in aliases and key in bucket:
|
||||
continue
|
||||
bucket[key] = raw_value
|
||||
if bucket:
|
||||
overrides[lang] = dict(sorted(bucket.items()))
|
||||
|
||||
return dict(sorted(overrides.items())), errors
|
||||
|
||||
|
||||
class JsonI18n:
|
||||
def __init__(self, path: str, default: str = "en", domain: str = "bot"):
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
default: str = "en",
|
||||
domain: str = "bot",
|
||||
overrides_path: Optional[str] = None,
|
||||
):
|
||||
self.domain = domain
|
||||
self.path = path
|
||||
self.default_lang = default
|
||||
self.base_locales_data: Dict[str, Dict[str, str]] = {}
|
||||
self.locale_overrides: LocaleOverrides = {}
|
||||
self.locales_data: Dict[str, Dict[str, str]] = {}
|
||||
self._overrides_path: Optional[Path] = None
|
||||
self._overrides_file_mtime_ns: Optional[int] = None
|
||||
self._overrides_file_next_check = 0.0
|
||||
self._overrides_file_check_interval_seconds = 1.0
|
||||
self._load_locales()
|
||||
if overrides_path:
|
||||
self.configure_overrides_file(overrides_path)
|
||||
self.reload_overrides_from_file(force=True)
|
||||
logging.info(
|
||||
f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501
|
||||
)
|
||||
@@ -26,13 +324,26 @@ class JsonI18n:
|
||||
if not os.path.isdir(self.path):
|
||||
logging.error(f"Locales path not found or not a directory: {self.path}")
|
||||
return
|
||||
loaded: Dict[str, Dict[str, str]] = {}
|
||||
for item in os.listdir(self.path):
|
||||
if item.endswith(".json"):
|
||||
lang_code = item.split(".")[0]
|
||||
file_path = os.path.join(self.path, item)
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
self.locales_data[lang_code] = json.load(f)
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
loaded[lang_code] = {
|
||||
str(key): str(value)
|
||||
for key, value in data.items()
|
||||
if isinstance(value, str)
|
||||
}
|
||||
else:
|
||||
logging.error(
|
||||
"Locale %s from %s is not a JSON object",
|
||||
lang_code,
|
||||
file_path,
|
||||
)
|
||||
except json.JSONDecodeError as e_json_load:
|
||||
logging.error(
|
||||
f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501
|
||||
@@ -42,24 +353,143 @@ class JsonI18n:
|
||||
f"Error loading locale {lang_code} from {file_path}: {e_load}",
|
||||
exc_info=True,
|
||||
)
|
||||
self.base_locales_data = loaded
|
||||
self._rebuild_effective_locales()
|
||||
|
||||
def _rebuild_effective_locales(self) -> None:
|
||||
effective: Dict[str, Dict[str, str]] = {}
|
||||
for lang, messages in self.base_locales_data.items():
|
||||
merged = dict(messages)
|
||||
merged.update(self.locale_overrides.get(lang, {}))
|
||||
effective[lang] = merged
|
||||
fallback_base = (
|
||||
self.base_locales_data.get(self.default_lang)
|
||||
or self.base_locales_data.get("en")
|
||||
or next(iter(self.base_locales_data.values()), {})
|
||||
)
|
||||
for lang, messages in self.locale_overrides.items():
|
||||
if lang in effective:
|
||||
continue
|
||||
merged = dict(fallback_base)
|
||||
merged.update(messages)
|
||||
effective[lang] = merged
|
||||
self.locales_data = effective
|
||||
|
||||
def _valid_keys_by_language(self) -> Dict[str, Set[str]]:
|
||||
return _valid_locale_keys_by_language(self.base_locales_data)
|
||||
|
||||
def language_options(self) -> List[Dict[str, Any]]:
|
||||
self.reload_overrides_from_file()
|
||||
return locale_language_options(
|
||||
self.locales_data.keys(),
|
||||
base_languages=self.base_locales_data.keys(),
|
||||
)
|
||||
|
||||
def set_locale_overrides(self, overrides: object) -> Dict[str, str]:
|
||||
normalized, errors = normalize_locale_overrides_payload(
|
||||
overrides,
|
||||
valid_languages=set(self.base_locales_data.keys()),
|
||||
valid_keys_by_language=self._valid_keys_by_language(),
|
||||
allow_extra_languages=True,
|
||||
)
|
||||
if errors:
|
||||
logging.warning("Some locale overrides were skipped: %s", errors)
|
||||
self.locale_overrides = normalized
|
||||
self._rebuild_effective_locales()
|
||||
return errors
|
||||
|
||||
def configure_overrides_file(self, path: str | Path) -> None:
|
||||
self._overrides_path = Path(path)
|
||||
try:
|
||||
self._overrides_file_mtime_ns = self._overrides_path.stat().st_mtime_ns
|
||||
except FileNotFoundError:
|
||||
self._overrides_file_mtime_ns = None
|
||||
except OSError as exc:
|
||||
logging.warning("Failed to stat locale overrides file %s: %s", path, exc)
|
||||
self._overrides_file_mtime_ns = None
|
||||
|
||||
def reload_overrides_from_file(self, *, force: bool = False) -> bool:
|
||||
if self._overrides_path is None:
|
||||
return False
|
||||
now = time.monotonic()
|
||||
if not force and now < self._overrides_file_next_check:
|
||||
return False
|
||||
self._overrides_file_next_check = now + self._overrides_file_check_interval_seconds
|
||||
|
||||
try:
|
||||
stat = self._overrides_path.stat()
|
||||
except FileNotFoundError:
|
||||
if self._overrides_file_mtime_ns is None:
|
||||
return False
|
||||
self._overrides_file_mtime_ns = None
|
||||
logging.info(
|
||||
"Locale overrides file removed; keeping current in-memory overrides until "
|
||||
"the DB fallback is reloaded"
|
||||
)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logging.warning(
|
||||
"Failed to stat locale overrides file %s: %s",
|
||||
self._overrides_path,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns:
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = json.loads(self._overrides_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
logging.warning(
|
||||
"Failed to parse locale overrides file %s: %s",
|
||||
self._overrides_path,
|
||||
exc,
|
||||
)
|
||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||
return False
|
||||
except OSError as exc:
|
||||
logging.warning(
|
||||
"Failed to read locale overrides file %s: %s",
|
||||
self._overrides_path,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||
self.set_locale_overrides(payload)
|
||||
logging.info("Locale overrides reloaded from %s", self._overrides_path)
|
||||
return True
|
||||
|
||||
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
|
||||
self.reload_overrides_from_file()
|
||||
lookup_key = resolve_locale_key(key)
|
||||
|
||||
requested_lang_code = normalize_locale_language_code(
|
||||
lang_code,
|
||||
set(self.locales_data.keys()),
|
||||
prefer_known_base=False,
|
||||
)
|
||||
requested_base_lang_code = requested_lang_code.split("-", 1)[0]
|
||||
|
||||
# Determine effective language with robust fallback
|
||||
if lang_code and lang_code in self.locales_data:
|
||||
effective_lang_code = lang_code
|
||||
if requested_lang_code and requested_lang_code in self.locales_data:
|
||||
effective_lang_code = requested_lang_code
|
||||
elif requested_base_lang_code and requested_base_lang_code in self.locales_data:
|
||||
effective_lang_code = requested_base_lang_code
|
||||
elif self.default_lang in self.locales_data:
|
||||
effective_lang_code = self.default_lang
|
||||
elif "en" in self.locales_data:
|
||||
effective_lang_code = "en"
|
||||
else:
|
||||
effective_lang_code = lang_code or self.default_lang
|
||||
effective_lang_code = requested_lang_code or self.default_lang
|
||||
|
||||
lang_data = self.locales_data.get(effective_lang_code)
|
||||
if lang_data is None:
|
||||
# Try explicit fallback to English if available
|
||||
fallback_data = self.locales_data.get("en")
|
||||
if fallback_data is not None:
|
||||
text = fallback_data.get(key)
|
||||
text = fallback_data.get(lookup_key)
|
||||
if text is not None:
|
||||
try:
|
||||
return text.format(**kwargs) if kwargs else text
|
||||
@@ -70,11 +500,11 @@ class JsonI18n:
|
||||
)
|
||||
return key.format(**kwargs) if kwargs else key
|
||||
|
||||
text = lang_data.get(key)
|
||||
text = lang_data.get(lookup_key)
|
||||
if text is None:
|
||||
if effective_lang_code != self.default_lang:
|
||||
default_lang_data = self.locales_data.get(self.default_lang, {})
|
||||
text = default_lang_data.get(key)
|
||||
text = default_lang_data.get(lookup_key)
|
||||
|
||||
if text is None:
|
||||
logging.warning(
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Optional
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_templates import EmailContent, render_login_code
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
@@ -70,8 +71,9 @@ def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optiona
|
||||
|
||||
|
||||
class EmailAuthService:
|
||||
def __init__(self, settings: Settings):
|
||||
def __init__(self, settings: Settings, i18n: Optional[JsonI18n] = None):
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
|
||||
def _smtp_attempts(self) -> list[SmtpAttempt]:
|
||||
attempts: list[SmtpAttempt] = []
|
||||
@@ -465,6 +467,7 @@ class EmailAuthService:
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
purpose=purpose,
|
||||
i18n=self.i18n,
|
||||
)
|
||||
|
||||
message = EmailMessage()
|
||||
|
||||
@@ -15,7 +15,7 @@ from dataclasses import dataclass
|
||||
from typing import Optional, Sequence, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
|
||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code
|
||||
from config.settings import Settings
|
||||
|
||||
_BG = "#05070a"
|
||||
@@ -64,7 +64,10 @@ def _brand_title(settings: Settings) -> str:
|
||||
|
||||
|
||||
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
|
||||
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
||||
return normalize_locale_language_code(
|
||||
language_code or settings.DEFAULT_LANGUAGE or "ru",
|
||||
prefer_known_base=False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
"""Load, persist and apply runtime overrides for localization strings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.middlewares.i18n import (
|
||||
JsonI18n,
|
||||
LocaleOverrides,
|
||||
is_valid_locale_language_code,
|
||||
normalize_locale_language_code,
|
||||
normalize_locale_overrides_payload,
|
||||
resolve_locale_key,
|
||||
)
|
||||
from db.dal import locale_overrides_dal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[3]
|
||||
LOCALE_OVERRIDES_PATH = APP_ROOT / "data" / "locales-overrides.json"
|
||||
|
||||
LOCALE_GROUPS = [
|
||||
{
|
||||
"id": "admin_navigation",
|
||||
"title": "Admin navigation and shared UI",
|
||||
"description": "Sidebar, section headers, toolbar actions, filters, and shared controls.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_nav_",
|
||||
"admin_section_",
|
||||
"admin_panel_title",
|
||||
"admin_back_to_panel",
|
||||
"admin_sidebar_",
|
||||
"admin_exit",
|
||||
"admin_menu",
|
||||
"admin_language",
|
||||
"admin_page_",
|
||||
"admin_close",
|
||||
"admin_collapse",
|
||||
"admin_expand",
|
||||
"admin_show",
|
||||
"admin_hide",
|
||||
"admin_loading",
|
||||
"admin_btn_",
|
||||
"admin_filter_",
|
||||
"admin_sort_",
|
||||
"admin_status_",
|
||||
"admin_badge_",
|
||||
"admin_aria_",
|
||||
"admin_search",
|
||||
"admin_clear",
|
||||
"admin_save",
|
||||
"admin_saving",
|
||||
"admin_add",
|
||||
"admin_apply",
|
||||
"admin_reset",
|
||||
"admin_copy",
|
||||
"admin_copied",
|
||||
"admin_error",
|
||||
"admin_unknown_action",
|
||||
"back_to_admin_panel_button",
|
||||
"back_to_ads_list_button",
|
||||
"back_to_stats_monitoring_button",
|
||||
"back_to_user_management_button",
|
||||
"prev_page_button",
|
||||
"next_page_button",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_dashboard",
|
||||
"title": "Admin dashboard and stats",
|
||||
"description": "Dashboard cards, revenue charts, panel sync status, and monitoring copy.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_stats_",
|
||||
"admin_financial_",
|
||||
"admin_enhanced_",
|
||||
"admin_panel_stats_",
|
||||
"admin_panel_traffic_",
|
||||
"admin_queue_",
|
||||
"admin_sync_status_",
|
||||
"admin_stats_button",
|
||||
"admin_sync_panel_button",
|
||||
"admin_sync_initiated_from_panel",
|
||||
"admin_total",
|
||||
"error_displaying_statistics",
|
||||
"inline_admin_",
|
||||
"inline_user_stats_",
|
||||
"inline_financial_",
|
||||
"inline_system_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_users",
|
||||
"title": "Admin users",
|
||||
"description": (
|
||||
"User lists, user cards, bans, grants, premium overrides, and direct messages."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_user_",
|
||||
"admin_users_",
|
||||
"admin_ban_",
|
||||
"admin_unban_",
|
||||
"admin_banned_",
|
||||
"admin_premium_override_",
|
||||
"admin_traffic_grant_",
|
||||
"admin_view_banned_",
|
||||
"user_card_",
|
||||
"user_premium_",
|
||||
"user_regular_",
|
||||
"user_traffic_",
|
||||
"user_override_",
|
||||
"premium_override_",
|
||||
"regular_override_",
|
||||
"traffic_grant_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_payments",
|
||||
"title": "Admin payments",
|
||||
"description": (
|
||||
"Payment tables, payment details, exports, provider labels, and payment stats."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_payment_",
|
||||
"admin_payments_",
|
||||
"admin_no_payments",
|
||||
"admin_view_payments",
|
||||
"admin_refresh_payments",
|
||||
"admin_export_payments",
|
||||
"admin_export_sent",
|
||||
"admin_amount",
|
||||
"admin_provider",
|
||||
"admin_description",
|
||||
"admin_date",
|
||||
"admin_csv_payment_",
|
||||
"admin_csv_amount",
|
||||
"admin_csv_currency",
|
||||
"admin_csv_provider",
|
||||
"admin_csv_status",
|
||||
"admin_csv_description",
|
||||
"admin_csv_units",
|
||||
"admin_csv_months",
|
||||
"admin_csv_created_at",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_promos_marketing",
|
||||
"title": "Admin promos, ads, and broadcasts",
|
||||
"description": "Promo management, ad campaigns, marketing tools, and broadcast workflows.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_promo_",
|
||||
"admin_promos_",
|
||||
"admin_bulk_promo_",
|
||||
"admin_ads_",
|
||||
"admin_ad_",
|
||||
"admin_broadcast_",
|
||||
"admin_create_promo_",
|
||||
"admin_create_bulk_promo_",
|
||||
"admin_active_promos_",
|
||||
"broadcast_",
|
||||
"confirm_broadcast_",
|
||||
"cancel_broadcast_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_tariffs",
|
||||
"title": "Admin tariffs",
|
||||
"description": (
|
||||
"Tariff catalog, tariff dialogs, legacy tariff rows, and trial tariff widgets."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": ("admin_tariff_", "admin_tariffs_", "admin_trial"),
|
||||
},
|
||||
{
|
||||
"id": "admin_support",
|
||||
"title": "Admin support inbox",
|
||||
"description": "Support ticket inbox, ticket filters, admin replies, and support statuses.",
|
||||
"audience": "internal",
|
||||
"prefixes": ("admin_support_",),
|
||||
},
|
||||
{
|
||||
"id": "admin_appearance",
|
||||
"title": "Admin appearance",
|
||||
"description": "Theme catalog, branding, logo, favicon, and public page links.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_themes_",
|
||||
"admin_appearance",
|
||||
"admin_settings_icon_",
|
||||
"admin_settings_field_webapp_",
|
||||
"admin_settings_field_subscription_mini_app_url",
|
||||
"admin_settings_field_support_link",
|
||||
"admin_settings_field_server_status_url",
|
||||
"admin_settings_field_terms_",
|
||||
"admin_settings_field_privacy_",
|
||||
"admin_settings_field_user_agreement_",
|
||||
"appearance_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_settings_payments",
|
||||
"title": "Admin payment settings",
|
||||
"description": (
|
||||
"Payment method toggles, prices, provider credentials, and webhook settings."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_settings_field_default_currency_",
|
||||
"admin_settings_field_month_",
|
||||
"admin_settings_field_rub_",
|
||||
"admin_settings_field_stars_",
|
||||
"admin_settings_field_traffic_packages_",
|
||||
"admin_settings_field_payment_methods_",
|
||||
"admin_settings_field_subscription_purchase_",
|
||||
"admin_settings_field_yookassa_",
|
||||
"admin_settings_field_freekassa_",
|
||||
"admin_settings_field_platega_",
|
||||
"admin_settings_field_severpay_",
|
||||
"admin_settings_field_cryptopay_",
|
||||
"admin_settings_field_wata_",
|
||||
"admin_settings_field_heleket_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_settings_subscriptions",
|
||||
"title": "Admin subscription settings",
|
||||
"description": (
|
||||
"Panel connection, default squads, trials, referrals, device limits, and guides."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_settings_field_panel_",
|
||||
"admin_settings_field_user_",
|
||||
"admin_settings_field_trial_",
|
||||
"admin_settings_field_referral_",
|
||||
"admin_settings_field_legacy_refs",
|
||||
"admin_settings_field_my_devices_",
|
||||
"admin_settings_field_subscription_guides_",
|
||||
"admin_settings_field_subscription_page_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_settings_notifications",
|
||||
"title": "Admin notifications and logs",
|
||||
"description": "Logging, required channel, subscription notifications, and support limits.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_settings_field_log_",
|
||||
"admin_settings_field_support_",
|
||||
"admin_settings_field_subscription_notifications_",
|
||||
"admin_settings_field_subscription_notify_",
|
||||
"admin_settings_field_required_",
|
||||
"admin_settings_field_disable_welcome_",
|
||||
"admin_settings_field_start_command_",
|
||||
"admin_settings_field_default_language_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_settings",
|
||||
"title": "Admin settings",
|
||||
"description": (
|
||||
"Settings screen groups, subsections, helper text, and uncategorized settings."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": ("admin_settings_",),
|
||||
},
|
||||
{
|
||||
"id": "admin_translations",
|
||||
"title": "Admin translations",
|
||||
"description": "Translation override screen, language controls, and locale group labels.",
|
||||
"audience": "internal",
|
||||
"prefixes": ("admin_translations_",),
|
||||
},
|
||||
{
|
||||
"id": "admin_logs",
|
||||
"title": "Admin logs and exports",
|
||||
"description": "Activity logs, log exports, CSV headers, and event detail labels.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_logs_",
|
||||
"admin_log_",
|
||||
"admin_all_logs_",
|
||||
"admin_view_logs_",
|
||||
"admin_export_logs_",
|
||||
"admin_no_logs",
|
||||
"admin_csv_header_",
|
||||
"admin_event",
|
||||
"admin_content",
|
||||
"csv_yes",
|
||||
"csv_no",
|
||||
"error_displaying_logs_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_misc",
|
||||
"title": "Admin miscellaneous",
|
||||
"description": (
|
||||
"Older bot-admin labels and admin-only strings that do not fit another section."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": ("admin_",),
|
||||
},
|
||||
{
|
||||
"id": "webapp",
|
||||
"title": "Mini App",
|
||||
"description": "User-facing Mini App screens, navigation, settings, and toasts.",
|
||||
"audience": "user",
|
||||
"prefixes": ("wa_",),
|
||||
},
|
||||
{
|
||||
"id": "bot_menu",
|
||||
"title": "Telegram bot menu",
|
||||
"description": "Start menu, inline buttons, language selector, and bot-only flows.",
|
||||
"audience": "user",
|
||||
"prefixes": (
|
||||
"main_menu_",
|
||||
"menu_",
|
||||
"bot_interface_",
|
||||
"choose_language",
|
||||
"language_",
|
||||
"back_",
|
||||
"cancel_",
|
||||
"connect_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "subscriptions",
|
||||
"title": "Subscriptions and devices",
|
||||
"description": (
|
||||
"Subscription status, install guides, traffic packages, trials, and devices."
|
||||
),
|
||||
"audience": "user",
|
||||
"prefixes": (
|
||||
"subscription_",
|
||||
"trial_",
|
||||
"tariff_",
|
||||
"traffic_",
|
||||
"device_",
|
||||
"devices_",
|
||||
"my_devices_",
|
||||
"install_",
|
||||
"config_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "payments",
|
||||
"title": "Payments",
|
||||
"description": "Payment provider flows, invoices, payment methods, and checkout messages.",
|
||||
"audience": "user",
|
||||
"prefixes": (
|
||||
"payment_",
|
||||
"pay_",
|
||||
"yookassa_",
|
||||
"free_kassa_",
|
||||
"freekassa_",
|
||||
"wata_",
|
||||
"heleket_",
|
||||
"cryptopay_",
|
||||
"platega_",
|
||||
"stars_",
|
||||
"autorenew_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "support",
|
||||
"title": "Support",
|
||||
"description": "Support links, ticket inbox copy, ticket statuses, and notifications.",
|
||||
"audience": "user",
|
||||
"prefixes": ("support_", "ticket_"),
|
||||
},
|
||||
{
|
||||
"id": "referrals_promos",
|
||||
"title": "Referrals and promos",
|
||||
"description": "Referral program, invite copy, promo codes, and bonuses.",
|
||||
"audience": "user",
|
||||
"prefixes": ("referral_", "promo_", "invite_", "inline_referral_"),
|
||||
},
|
||||
{
|
||||
"id": "auth_security",
|
||||
"title": "Auth and security",
|
||||
"description": "Login, email verification, account linking, and security messages.",
|
||||
"audience": "user",
|
||||
"prefixes": (
|
||||
"auth_",
|
||||
"login_",
|
||||
"password_",
|
||||
"security_",
|
||||
"webapp_auth_",
|
||||
"channel_subscription_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "emails",
|
||||
"title": "Emails",
|
||||
"description": "Transactional emails sent to users: login codes, payments, and reminders.",
|
||||
"audience": "user",
|
||||
"prefixes": ("email_",),
|
||||
},
|
||||
{
|
||||
"id": "notifications_sync",
|
||||
"title": "Notifications and sync",
|
||||
"description": "Admin notifications, panel sync, logs, and background status messages.",
|
||||
"audience": "internal",
|
||||
"prefixes": ("notification_", "notifications_", "sync_", "log_", "panel_"),
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_LOCALE_GROUP = {
|
||||
"id": "common",
|
||||
"title": "Common",
|
||||
"description": "Shared buttons, statuses, validation errors, and uncategorized strings.",
|
||||
"audience": "user",
|
||||
"prefixes": (),
|
||||
}
|
||||
|
||||
INTERNAL_LOCALE_KEY_PREFIXES = (
|
||||
"admin_",
|
||||
"log_",
|
||||
"notification_",
|
||||
"notifications_",
|
||||
"panel_",
|
||||
"sync_",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocaleOverridesFileState:
|
||||
exists: bool
|
||||
readable: bool
|
||||
overrides: LocaleOverrides
|
||||
|
||||
|
||||
def _valid_languages(i18n: JsonI18n) -> set[str]:
|
||||
return set((i18n.base_locales_data or i18n.locales_data or {}).keys())
|
||||
|
||||
|
||||
def _valid_keys_by_language(i18n: JsonI18n) -> Dict[str, set[str]]:
|
||||
source = i18n.base_locales_data or i18n.locales_data or {}
|
||||
return {
|
||||
lang: {str(key) for key in messages}
|
||||
for lang, messages in source.items()
|
||||
if isinstance(messages, dict)
|
||||
}
|
||||
|
||||
|
||||
def _normalize_for_i18n(i18n: JsonI18n, payload: object) -> tuple[LocaleOverrides, Dict[str, str]]:
|
||||
return normalize_locale_overrides_payload(
|
||||
payload,
|
||||
valid_languages=_valid_languages(i18n),
|
||||
valid_keys_by_language=_valid_keys_by_language(i18n),
|
||||
allow_extra_languages=True,
|
||||
)
|
||||
|
||||
|
||||
def _flatten(overrides: LocaleOverrides) -> Iterable[Tuple[str, str, str]]:
|
||||
for lang, messages in overrides.items():
|
||||
for key, value in messages.items():
|
||||
yield lang, key, value
|
||||
|
||||
|
||||
def _flat_map(overrides: LocaleOverrides) -> Dict[Tuple[str, str], str]:
|
||||
return {(lang, key): value for lang, key, value in _flatten(overrides)}
|
||||
|
||||
|
||||
def _count_overrides(overrides: LocaleOverrides) -> int:
|
||||
return sum(len(messages) for messages in overrides.values())
|
||||
|
||||
|
||||
def _read_locale_overrides_file_state(
|
||||
i18n: JsonI18n,
|
||||
*,
|
||||
path: Path = LOCALE_OVERRIDES_PATH,
|
||||
) -> LocaleOverridesFileState:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return LocaleOverridesFileState(exists=False, readable=False, overrides={})
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to read locale overrides from %s: %s", path, exc)
|
||||
return LocaleOverridesFileState(exists=True, readable=False, overrides={})
|
||||
|
||||
overrides, errors = _normalize_for_i18n(i18n, payload)
|
||||
if errors:
|
||||
logger.warning("Skipping invalid locale override entries from %s: %s", path, errors)
|
||||
return LocaleOverridesFileState(exists=True, readable=True, overrides=overrides)
|
||||
|
||||
|
||||
def read_locale_overrides_file(
|
||||
i18n: JsonI18n,
|
||||
*,
|
||||
path: Path = LOCALE_OVERRIDES_PATH,
|
||||
) -> LocaleOverrides:
|
||||
return _read_locale_overrides_file_state(i18n, path=path).overrides
|
||||
|
||||
|
||||
def write_locale_overrides_file(
|
||||
overrides: LocaleOverrides,
|
||||
*,
|
||||
path: Path = LOCALE_OVERRIDES_PATH,
|
||||
) -> bool:
|
||||
payload = {
|
||||
lang: dict(sorted(messages.items()))
|
||||
for lang, messages in sorted(overrides.items())
|
||||
if messages
|
||||
}
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write locale overrides to %s: %s", path, exc)
|
||||
return False
|
||||
|
||||
|
||||
async def _replace_db_overrides(
|
||||
session: AsyncSession,
|
||||
desired_overrides: LocaleOverrides,
|
||||
*,
|
||||
updated_by: Optional[int] = None,
|
||||
) -> int:
|
||||
current = _flat_map(await locale_overrides_dal.get_all_overrides(session))
|
||||
desired = _flat_map(desired_overrides)
|
||||
changes: Dict[Tuple[str, str], Tuple[bool, str]] = {}
|
||||
|
||||
for identity, value in desired.items():
|
||||
if current.get(identity) != value:
|
||||
changes[identity] = (True, value)
|
||||
for identity in current:
|
||||
if identity not in desired:
|
||||
changes[identity] = (False, "")
|
||||
|
||||
if changes:
|
||||
await locale_overrides_dal.bulk_apply(session, updates=changes, updated_by=updated_by)
|
||||
return len(changes)
|
||||
|
||||
|
||||
async def load_locale_overrides(
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
overrides_path: Path = LOCALE_OVERRIDES_PATH,
|
||||
) -> int:
|
||||
"""Load locale overrides and keep the DB mirror in sync.
|
||||
|
||||
A valid JSON file is the source of truth. The DB is used as a fallback only
|
||||
when the file is missing or cannot be read/parsed.
|
||||
"""
|
||||
|
||||
i18n.configure_overrides_file(overrides_path)
|
||||
file_state = _read_locale_overrides_file_state(i18n, path=overrides_path)
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
if file_state.readable:
|
||||
async with session.begin():
|
||||
changed = await _replace_db_overrides(
|
||||
session,
|
||||
file_state.overrides,
|
||||
updated_by=None,
|
||||
)
|
||||
i18n.set_locale_overrides(file_state.overrides)
|
||||
i18n.configure_overrides_file(overrides_path)
|
||||
logger.info(
|
||||
"Applied %s locale overrides from %s and synced %s DB rows",
|
||||
_count_overrides(file_state.overrides),
|
||||
overrides_path,
|
||||
changed,
|
||||
)
|
||||
return _count_overrides(file_state.overrides)
|
||||
|
||||
db_overrides = await locale_overrides_dal.get_all_overrides(session)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not load locale overrides from DB: %s", exc)
|
||||
if file_state.readable:
|
||||
i18n.set_locale_overrides(file_state.overrides)
|
||||
return _count_overrides(file_state.overrides)
|
||||
i18n.set_locale_overrides({})
|
||||
return 0
|
||||
|
||||
normalized, errors = _normalize_for_i18n(i18n, db_overrides)
|
||||
if errors:
|
||||
logger.warning("Skipping invalid DB locale override entries: %s", errors)
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
changed = await _replace_db_overrides(session, normalized, updated_by=None)
|
||||
if changed:
|
||||
logger.info("Canonicalized %s DB locale override rows", changed)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not canonicalize DB locale overrides: %s", exc)
|
||||
i18n.set_locale_overrides(normalized)
|
||||
if not file_state.exists:
|
||||
logger.info(
|
||||
"Locale overrides file %s is missing; trying to bootstrap it from DB state",
|
||||
overrides_path,
|
||||
)
|
||||
file_written = write_locale_overrides_file(normalized, path=overrides_path)
|
||||
if file_written:
|
||||
i18n.configure_overrides_file(overrides_path)
|
||||
logger.info("Created locale overrides file %s from DB state", overrides_path)
|
||||
logger.info("Applied %s locale overrides from DB fallback", _count_overrides(normalized))
|
||||
return _count_overrides(normalized)
|
||||
|
||||
|
||||
async def update_locale_overrides(
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
updates: Dict[str, Dict[str, Any]],
|
||||
deletes: Optional[List[Dict[str, str]]] = None,
|
||||
actor_id: Optional[int] = None,
|
||||
overrides_path: Path = LOCALE_OVERRIDES_PATH,
|
||||
) -> Dict[str, Any]:
|
||||
deletes = list(deletes or [])
|
||||
normalized_updates, errors = _normalize_for_i18n(i18n, updates)
|
||||
|
||||
normalized_deletes: List[Tuple[str, str]] = []
|
||||
valid_languages = _valid_languages(i18n)
|
||||
valid_keys = {key for keys in _valid_keys_by_language(i18n).values() for key in keys}
|
||||
for item in deletes:
|
||||
if not isinstance(item, dict):
|
||||
errors.setdefault("_deletes", "invalid_delete")
|
||||
continue
|
||||
lang = normalize_locale_language_code(
|
||||
item.get("lang"),
|
||||
valid_languages=None,
|
||||
prefer_known_base=False,
|
||||
)
|
||||
key = resolve_locale_key(item.get("key"))
|
||||
error_key = f"{lang or '_language'}.{key or '_key'}"
|
||||
if lang not in valid_languages and not is_valid_locale_language_code(lang):
|
||||
errors.setdefault(error_key, "invalid_language")
|
||||
continue
|
||||
if key not in valid_keys:
|
||||
errors.setdefault(error_key, "unknown_key")
|
||||
continue
|
||||
normalized_deletes.append((lang, key))
|
||||
|
||||
if errors:
|
||||
return {"ok": False, "errors": errors}
|
||||
|
||||
async with async_session_factory() as session:
|
||||
db_overrides = await locale_overrides_dal.get_all_overrides(session)
|
||||
|
||||
file_state = _read_locale_overrides_file_state(i18n, path=overrides_path)
|
||||
source_overrides = file_state.overrides if file_state.readable else db_overrides
|
||||
desired, source_errors = _normalize_for_i18n(i18n, source_overrides)
|
||||
if source_errors:
|
||||
logger.warning("Skipping invalid locale override entries before update: %s", source_errors)
|
||||
|
||||
for lang, messages in normalized_updates.items():
|
||||
desired.setdefault(lang, {}).update(messages)
|
||||
for lang, key in normalized_deletes:
|
||||
if lang in desired:
|
||||
desired[lang].pop(key, None)
|
||||
if not desired[lang]:
|
||||
desired.pop(lang, None)
|
||||
desired = {
|
||||
lang: dict(sorted(messages.items()))
|
||||
for lang, messages in sorted(desired.items())
|
||||
if messages
|
||||
}
|
||||
|
||||
file_written = write_locale_overrides_file(desired, path=overrides_path)
|
||||
if not file_written and file_state.exists and file_state.readable:
|
||||
return {"ok": False, "errors": {"_file": "write_failed"}}
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
await _replace_db_overrides(session, desired, updated_by=actor_id)
|
||||
|
||||
i18n.set_locale_overrides(desired)
|
||||
if file_written:
|
||||
i18n.configure_overrides_file(overrides_path)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"applied": sum(len(messages) for messages in normalized_updates.values()),
|
||||
"reverted": len(normalized_deletes),
|
||||
"file_written": file_written,
|
||||
}
|
||||
|
||||
|
||||
def group_id_for_locale_key(key: str) -> str:
|
||||
for group in LOCALE_GROUPS:
|
||||
if any(key.startswith(prefix) or key == prefix for prefix in group["prefixes"]):
|
||||
return str(group["id"])
|
||||
return str(DEFAULT_LOCALE_GROUP["id"])
|
||||
|
||||
|
||||
def audience_for_locale_key(key: str) -> str:
|
||||
if key.startswith(INTERNAL_LOCALE_KEY_PREFIXES):
|
||||
return "internal"
|
||||
group_id = group_id_for_locale_key(key)
|
||||
for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]:
|
||||
if group["id"] == group_id:
|
||||
return str(group.get("audience") or "user")
|
||||
return "user"
|
||||
|
||||
|
||||
def locale_group_catalog() -> List[Dict[str, Any]]:
|
||||
catalog: List[Dict[str, Any]] = []
|
||||
for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]:
|
||||
item = {key: value for key, value in group.items() if key != "prefixes"}
|
||||
item["title_key"] = f"translations_group_{item['id']}"
|
||||
item["description_key"] = f"translations_group_{item['id']}_hint"
|
||||
catalog.append(item)
|
||||
return catalog
|
||||
@@ -206,8 +206,9 @@ class PanelWebhookService:
|
||||
days_left=days_left,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
||||
i18n=self.i18n,
|
||||
)
|
||||
email_service = EmailAuthService(self.settings)
|
||||
email_service = EmailAuthService(self.settings, self.i18n)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
||||
|
||||
@@ -95,6 +95,7 @@ class PaymentContextMixin:
|
||||
except Exception:
|
||||
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
|
||||
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
|
||||
i18n = getattr(self, "i18n", None)
|
||||
|
||||
try:
|
||||
content = render_payment_success(
|
||||
@@ -108,8 +109,9 @@ class PaymentContextMixin:
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=dashboard_url,
|
||||
provider_label=provider_label,
|
||||
i18n=i18n,
|
||||
)
|
||||
email_service = EmailAuthService(self.settings)
|
||||
email_service = EmailAuthService(self.settings, i18n)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception("Failed to send payment success email to user %s", db_user.user_id)
|
||||
|
||||
@@ -125,7 +125,7 @@ class SupportService:
|
||||
self.settings = settings
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self.email_auth_service = email_auth_service or EmailAuthService(settings)
|
||||
self.email_auth_service = email_auth_service or EmailAuthService(settings, i18n)
|
||||
self.notification_service = notification_service or NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from . import (
|
||||
ad_dal,
|
||||
app_settings_dal,
|
||||
locale_overrides_dal,
|
||||
message_log_dal,
|
||||
panel_sync_dal,
|
||||
payment_dal,
|
||||
@@ -23,5 +24,6 @@ __all__ = (
|
||||
"ad_dal",
|
||||
"security_dal",
|
||||
"app_settings_dal",
|
||||
"locale_overrides_dal",
|
||||
"support_dal",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Persistent overrides for localization strings."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import LocaleOverride
|
||||
|
||||
|
||||
async def get_all_overrides(session: AsyncSession) -> Dict[str, Dict[str, str]]:
|
||||
rows = (await session.execute(select(LocaleOverride))).scalars().all()
|
||||
result: Dict[str, Dict[str, str]] = {}
|
||||
for row in rows:
|
||||
result.setdefault(row.lang, {})[row.key] = row.value
|
||||
return result
|
||||
|
||||
|
||||
async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, object]]:
|
||||
rows = (await session.execute(select(LocaleOverride))).scalars().all()
|
||||
items: List[Dict[str, object]] = []
|
||||
for row in rows:
|
||||
items.append(
|
||||
{
|
||||
"lang": row.lang,
|
||||
"key": row.key,
|
||||
"value": row.value,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"updated_by": row.updated_by,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
async def upsert_override(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
lang: str,
|
||||
key: str,
|
||||
value: str,
|
||||
updated_by: Optional[int],
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
pg_insert(LocaleOverride)
|
||||
.values(lang=lang, key=key, value=value, updated_at=now, updated_by=updated_by)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[LocaleOverride.lang, LocaleOverride.key],
|
||||
set_={
|
||||
"value": value,
|
||||
"updated_at": now,
|
||||
"updated_by": updated_by,
|
||||
},
|
||||
)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
|
||||
|
||||
async def delete_override(session: AsyncSession, *, lang: str, key: str) -> bool:
|
||||
stmt = delete(LocaleOverride).where(
|
||||
LocaleOverride.lang == lang,
|
||||
LocaleOverride.key == key,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return bool(result.rowcount or 0)
|
||||
|
||||
|
||||
async def bulk_apply(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
updates: Dict[Tuple[str, str], Tuple[bool, str]],
|
||||
updated_by: Optional[int],
|
||||
) -> None:
|
||||
for (lang, key), (set_flag, value) in updates.items():
|
||||
if set_flag:
|
||||
await upsert_override(
|
||||
session,
|
||||
lang=lang,
|
||||
key=key,
|
||||
value=value,
|
||||
updated_by=updated_by,
|
||||
)
|
||||
else:
|
||||
await delete_override(session, lang=lang, key=key)
|
||||
@@ -902,6 +902,23 @@ def _migration_0027_add_subscription_install_share_token(connection: Connection)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0028_add_locale_overrides(connection: Connection) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS locale_overrides (
|
||||
lang VARCHAR(16) NOT NULL,
|
||||
key VARCHAR(255) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by BIGINT,
|
||||
PRIMARY KEY (lang, key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -1049,6 +1066,11 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Add stable public share tokens for install instructions",
|
||||
upgrade=_migration_0027_add_subscription_install_share_token,
|
||||
),
|
||||
Migration(
|
||||
id="0028_add_locale_overrides",
|
||||
description="Persist runtime overrides for localization strings",
|
||||
upgrade=_migration_0028_add_locale_overrides,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -489,3 +489,18 @@ class AppSettingOverride(Base):
|
||||
nullable=False,
|
||||
)
|
||||
updated_by = Column(BigInteger, nullable=True)
|
||||
|
||||
|
||||
class LocaleOverride(Base):
|
||||
__tablename__ = "locale_overrides"
|
||||
|
||||
lang = Column(String(16), primary_key=True)
|
||||
key = Column(String(255), primary_key=True)
|
||||
value = Column(Text, nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_by = Column(BigInteger, nullable=True)
|
||||
|
||||
@@ -23,6 +23,7 @@ from bot.payment_providers.yookassa import (
|
||||
process_cancelled_payment,
|
||||
process_successful_payment,
|
||||
)
|
||||
from bot.services.locale_override_service import load_locale_overrides
|
||||
from bot.services.settings_override_service import load_overrides_from_db
|
||||
from bot.services.tariff_worker import TariffTrafficWorker
|
||||
from bot.utils.message_queue import init_queue_manager
|
||||
@@ -37,6 +38,7 @@ async def _build_worker_context(settings):
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
i18n = get_i18n_instance(path="locales", default=settings.DEFAULT_LANGUAGE)
|
||||
await load_locale_overrides(i18n, session_factory)
|
||||
bot_username = "your_bot_username"
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
|
||||
Reference in New Issue
Block a user