feat: add runtime locale overrides

This commit is contained in:
3252a8
2026-05-25 17:47:45 +03:00
parent 23784d00fe
commit 59fa301344
51 changed files with 4026 additions and 214 deletions
+1 -1
View File
@@ -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,
+2
View File
@@ -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),
}
)
+5
View File
@@ -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)
+1 -1
View File
@@ -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": {}}
+10
View File
@@ -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(
+5 -9
View File
@@ -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,
+6 -2
View File
@@ -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:
+1 -1
View File
@@ -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,
)
+14 -2
View File
@@ -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:
+13 -8
View File
@@ -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()
+2
View File
@@ -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"
+439 -9
View File
@@ -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(
+4 -1
View File
@@ -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()
+5 -2
View File
@@ -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)
+1 -1
View File
@@ -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,
+2
View File
@@ -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",
)
+86
View File
@@ -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)
+22
View File
@@ -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,
),
]
+15
View File
@@ -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)
+2
View File
@@ -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()