feat: add translation language setting & onboarding flow

- Add separate translation_language setting (independent from interface language)
- Implement 3-step onboarding for new users:
  1. Choose interface language
  2. Choose learning language
  3. Choose translation language
- Fix localization issues when using callback.message (user_id from state)
- Add UserService.get_user_by_id() method
- Add get_user_translation_lang() helper in i18n
- Update all handlers to use correct translation language
- Add localization keys for onboarding (ru/en/ja)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-07 16:35:08 +03:00
parent d937b37a3b
commit 3e5c1be464
14 changed files with 360 additions and 81 deletions

View File

@@ -10,7 +10,7 @@ from database.models import WordSource
from services.user_service import UserService
from services.vocabulary_service import VocabularyService
from services.ai_service import ai_service
from utils.i18n import t, get_user_lang
from utils.i18n import t, get_user_lang, get_user_translation_lang
from utils.levels import get_user_level_for_language
router = Router()
@@ -87,7 +87,7 @@ async def process_text(message: Message, state: FSMContext):
level=current_level,
max_words=15,
learning_lang=user.learning_language,
translation_lang=user.language_interface,
translation_lang=get_user_translation_lang(user),
)
await processing_msg.delete()
@@ -176,27 +176,29 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext):
user_id = data.get('user_id')
if word_index >= len(words):
await callback.answer("❌ Ошибка: слово не найдено")
await callback.answer(t('ru', 'words.err_not_found'))
return
word_data = words[word_index]
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
session, user_id, word_data['word']
)
if existing:
await callback.answer(f"Слово '{word_data['word']}' уже в словаре", show_alert=True)
await callback.answer(t(lang, 'words.already_exists', word=word_data['word']), show_alert=True)
return
# Добавляем слово
learn = user.learning_language if user else 'en'
ui = user.language_interface if user else 'ru'
translation_lang = get_user_translation_lang(user)
ctx = word_data.get('context')
examples = ([{learn: ctx, ui: ''}] if ctx else [])
examples = ([{learn: ctx, translation_lang: ''}] if ctx else [])
await VocabularyService.add_word(
session=session,
@@ -204,7 +206,7 @@ async def import_single_word(callback: CallbackQuery, state: FSMContext):
word_original=word_data['word'],
word_translation=word_data['translation'],
source_lang=user.learning_language if user else None,
translation_lang=user.language_interface if user else None,
translation_lang=translation_lang,
transcription=word_data.get('transcription'),
examples=examples,
source=WordSource.CONTEXT,
@@ -242,9 +244,9 @@ async def import_all_words(callback: CallbackQuery, state: FSMContext):
# Добавляем слово
learn = user.learning_language if user else 'en'
ui = user.language_interface if user else 'ru'
translation_lang = get_user_translation_lang(user)
ctx = word_data.get('context')
examples = ([{learn: ctx, ui: ''}] if ctx else [])
examples = ([{learn: ctx, translation_lang: ''}] if ctx else [])
await VocabularyService.add_word(
session=session,
@@ -252,7 +254,7 @@ async def import_all_words(callback: CallbackQuery, state: FSMContext):
word_original=word_data['word'],
word_translation=word_data['translation'],
source_lang=user.learning_language if user else None,
translation_lang=user.language_interface if user else None,
translation_lang=translation_lang,
transcription=word_data.get('transcription'),
examples=examples,
source=WordSource.CONTEXT,
@@ -389,7 +391,7 @@ async def handle_file_import(message: Message, state: FSMContext, bot: Bot):
translations = await ai_service.translate_words_batch(
words=words_to_translate,
source_lang=user.learning_language,
translation_lang=user.language_interface
translation_lang=get_user_translation_lang(user)
)
await processing_msg.delete()
@@ -490,7 +492,7 @@ async def import_file_all_words(callback: CallbackQuery, state: FSMContext):
word_original=word_data['word'],
word_translation=word_data.get('translation', ''),
source_lang=user.learning_language if user else None,
translation_lang=user.language_interface if user else None,
translation_lang=get_user_translation_lang(user),
transcription=word_data.get('transcription'),
source=WordSource.IMPORT
)

View File

@@ -24,11 +24,14 @@ async def cmd_level_test(message: Message, state: FSMContext):
await start_level_test(message, state)
async def start_level_test(message: Message, state: FSMContext):
async def start_level_test(message: Message, state: FSMContext, telegram_id: int = None):
"""Начать тест определения уровня"""
# Определяем ID пользователя (telegram_id передаётся при вызове из callback)
user_telegram_id = telegram_id or message.from_user.id
# Показываем описание теста
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
user = await UserService.get_user_by_telegram_id(session, user_telegram_id)
lang = (user.language_interface if user else 'ru') or 'ru'
await message.answer(t(lang, 'level_test.intro'))
@@ -83,7 +86,8 @@ async def begin_test(callback: CallbackQuery, state: FSMContext):
current_question=0,
correct_answers=0,
answers=[], # Для отслеживания ответов по уровням
learning_language=learning_lang
learning_language=learning_lang,
user_id=user.id
)
await state.set_state(LevelTestStates.taking_test)
@@ -96,6 +100,7 @@ async def show_question(message: Message, state: FSMContext):
data = await state.get_data()
questions = data.get('questions', [])
current_idx = data.get('current_question', 0)
user_id = data.get('user_id')
if current_idx >= len(questions):
# Тест завершён
@@ -105,9 +110,9 @@ async def show_question(message: Message, state: FSMContext):
question = questions[current_idx]
# Формируем текст вопроса
# Язык интерфейса
# Язык интерфейса (берём user_id из state, т.к. message может быть от бота)
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
user = await UserService.get_user_by_id(session, user_id)
lang = (user.language_interface if user else 'ru') or 'ru'
text = (
@@ -127,10 +132,6 @@ async def show_question(message: Message, state: FSMContext):
])
# Кнопка для показа перевода вопроса (локализованная)
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.chat.id)
from utils.i18n import t
lang = (user.language_interface if user else 'ru') or 'ru'
keyboard.append([
InlineKeyboardButton(text=t(lang, 'level_test.show_translation_btn'), callback_data=f"show_qtr_{current_idx}")
])
@@ -237,6 +238,7 @@ async def finish_test(message: Message, state: FSMContext):
correct_answers = data.get('correct_answers', 0)
answers = data.get('answers', [])
learning_lang = data.get('learning_language', 'en')
user_id = data.get('user_id')
total = len(questions)
accuracy = int((correct_answers / total) * 100) if total > 0 else 0
@@ -244,9 +246,9 @@ async def finish_test(message: Message, state: FSMContext):
# Определяем уровень на основе правильных ответов по уровням
level = determine_level(answers, learning_lang)
# Сохраняем уровень в базе данных
# Сохраняем уровень в базе данных (берём user_id из state, т.к. message может быть от бота)
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.chat.id)
user = await UserService.get_user_by_id(session, user_id)
if user:
await UserService.update_user_level(session, user.id, level, learning_lang)

View File

@@ -17,10 +17,16 @@ from utils.levels import (
router = Router()
def get_translation_language(user) -> str:
"""Получить язык перевода (translation_language или language_interface как fallback)"""
return getattr(user, 'translation_language', None) or getattr(user, 'language_interface', 'ru') or 'ru'
def get_settings_keyboard(user) -> InlineKeyboardMarkup:
"""Создать клавиатуру настроек"""
lang = get_user_lang(user)
ui_lang_code = getattr(user, 'language_interface', 'ru') or 'ru'
translation_lang_code = get_translation_language(user)
current_level = get_user_level_for_language(user)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
@@ -35,6 +41,10 @@ def get_settings_keyboard(user) -> InlineKeyboardMarkup:
text=t(lang, 'settings.interface_prefix') + t(lang, f'settings.lang_name.{ui_lang_code}'),
callback_data="settings_language"
)],
[InlineKeyboardButton(
text=t(lang, 'settings.translation_prefix') + t(lang, f'settings.lang_name.{translation_lang_code}'),
callback_data="settings_translation"
)],
[InlineKeyboardButton(
text=t(lang, 'settings.close'),
callback_data="settings_close"
@@ -73,6 +83,18 @@ def get_language_keyboard(user=None) -> InlineKeyboardMarkup:
return keyboard
def get_translation_language_keyboard(user=None) -> InlineKeyboardMarkup:
"""Клавиатура выбора языка перевода"""
lang = get_user_lang(user)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'settings.lang_name.ru'), callback_data="set_translation_ru")],
[InlineKeyboardButton(text=t(lang, 'settings.lang_name.en'), callback_data="set_translation_en")],
[InlineKeyboardButton(text=t(lang, 'settings.lang_name.ja'), callback_data="set_translation_ja")],
[InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="settings_back")]
])
return keyboard
def get_learning_language_keyboard(user=None) -> InlineKeyboardMarkup:
"""Клавиатура выбора языка изучения"""
lang = get_user_lang(user)
@@ -214,6 +236,40 @@ async def set_language(callback: CallbackQuery):
await callback.answer()
@router.callback_query(F.data == "settings_translation")
async def settings_translation(callback: CallbackQuery):
"""Показать выбор языка перевода"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
lang = get_user_lang(user)
await callback.message.edit_text(
t(lang, 'settings.translation_title') + t(lang, 'settings.translation_desc'),
reply_markup=get_translation_language_keyboard(user)
)
await callback.answer()
@router.callback_query(F.data.startswith("set_translation_"))
async def set_translation_language(callback: CallbackQuery):
"""Установить язык перевода"""
new_translation_lang = callback.data.split("_")[-1] # ru | en | ja
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if user:
await UserService.update_user_translation_language(session, user.id, new_translation_lang)
lang = get_user_lang(user)
lang_name = t(lang, f'settings.lang_name.{new_translation_lang}')
text = t(lang, 'settings.translation_changed', lang_name=lang_name)
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="settings_back")]])
)
await callback.answer()
@router.callback_query(F.data == "settings_back")
async def settings_back(callback: CallbackQuery):
"""Вернуться к настройкам"""

View File

@@ -9,14 +9,49 @@ from aiogram.types import (
KeyboardButton,
)
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from database.db import async_session_maker
from services.user_service import UserService
from utils.i18n import t
from utils.i18n import t, get_user_translation_lang
from utils.levels import get_user_level_for_language
router = Router()
class OnboardingStates(StatesGroup):
"""Состояния онбординга для новых пользователей"""
choosing_interface_lang = State()
choosing_learning_lang = State()
choosing_translation_lang = State()
def onboarding_interface_keyboard() -> InlineKeyboardMarkup:
"""Клавиатура выбора языка интерфейса при онбординге"""
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🇷🇺 Русский", callback_data="onboard_interface_ru")],
[InlineKeyboardButton(text="🇬🇧 English", callback_data="onboard_interface_en")],
[InlineKeyboardButton(text="🇯🇵 日本語", callback_data="onboard_interface_ja")],
])
def onboarding_learning_keyboard(lang: str) -> InlineKeyboardMarkup:
"""Клавиатура выбора языка изучения при онбординге"""
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'onboarding.lang_en'), callback_data="onboard_learning_en")],
[InlineKeyboardButton(text=t(lang, 'onboarding.lang_ja'), callback_data="onboard_learning_ja")],
])
def onboarding_translation_keyboard(lang: str) -> InlineKeyboardMarkup:
"""Клавиатура выбора языка перевода при онбординге"""
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'settings.lang_name.ru'), callback_data="onboard_translation_ru")],
[InlineKeyboardButton(text=t(lang, 'settings.lang_name.en'), callback_data="onboard_translation_en")],
[InlineKeyboardButton(text=t(lang, 'settings.lang_name.ja'), callback_data="onboard_translation_ja")],
])
def main_menu_keyboard(lang: str = 'ru') -> ReplyKeyboardMarkup:
"""Клавиатура с основными командами (кнопки отправляют команды)."""
return ReplyKeyboardMarkup(
@@ -46,35 +81,111 @@ async def cmd_start(message: Message, state: FSMContext):
existing_user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
is_new_user = existing_user is None
# Создаём или получаем пользователя
user = await UserService.get_or_create_user(
session,
telegram_id=message.from_user.id,
username=message.from_user.username
)
if is_new_user:
# Новый пользователь - начинаем онбординг
# Сначала создаём пользователя с дефолтными значениями
user = await UserService.get_or_create_user(
session,
telegram_id=message.from_user.id,
username=message.from_user.username
)
# Приветствие и первый вопрос - язык интерфейса
await message.answer(
f"👋 Welcome! / Привет! / ようこそ!\n\n"
"🌐 Choose your interface language:\n"
"🌐 Выбери язык интерфейса:\n"
"🌐 インターフェース言語を選択:",
reply_markup=onboarding_interface_keyboard()
)
await state.set_state(OnboardingStates.choosing_interface_lang)
return
# Существующий пользователь
user = existing_user
lang = (user.language_interface or 'ru')
if is_new_user:
# Новый пользователь
await message.answer(
t(lang, "start.new_intro", first_name=message.from_user.first_name),
reply_markup=main_menu_keyboard(lang),
)
await message.answer(
t(lang, "start.return", first_name=message.from_user.first_name),
reply_markup=main_menu_keyboard(lang),
)
# Предлагаем пройти тест уровня
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'start.offer_btn'), callback_data="offer_level_test")],
[InlineKeyboardButton(text=t(lang, 'start.skip_btn'), callback_data="skip_level_test")]
])
await message.answer(t(lang, "start.offer_test"), reply_markup=keyboard)
else:
# Существующий пользователь
await message.answer(
t(lang, "start.return", first_name=message.from_user.first_name),
reply_markup=main_menu_keyboard(lang),
)
# === Обработчики онбординга ===
@router.callback_query(F.data.startswith("onboard_interface_"), OnboardingStates.choosing_interface_lang)
async def onboard_set_interface(callback: CallbackQuery, state: FSMContext):
"""Установить язык интерфейса при онбординге"""
lang = callback.data.split("_")[-1] # ru | en | ja
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if user:
await UserService.update_user_language(session, user.id, lang)
await state.update_data(interface_lang=lang)
# Второй вопрос - язык изучения
await callback.message.edit_text(
t(lang, 'onboarding.step2_title'),
reply_markup=onboarding_learning_keyboard(lang)
)
await state.set_state(OnboardingStates.choosing_learning_lang)
await callback.answer()
@router.callback_query(F.data.startswith("onboard_learning_"), OnboardingStates.choosing_learning_lang)
async def onboard_set_learning(callback: CallbackQuery, state: FSMContext):
"""Установить язык изучения при онбординге"""
learning_lang = callback.data.split("_")[-1] # en | ja
data = await state.get_data()
lang = data.get('interface_lang', 'ru')
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if user:
await UserService.update_user_learning_language(session, user.id, learning_lang)
await state.update_data(learning_lang=learning_lang)
# Третий вопрос - язык перевода
await callback.message.edit_text(
t(lang, 'onboarding.step3_title'),
reply_markup=onboarding_translation_keyboard(lang)
)
await state.set_state(OnboardingStates.choosing_translation_lang)
await callback.answer()
@router.callback_query(F.data.startswith("onboard_translation_"), OnboardingStates.choosing_translation_lang)
async def onboard_set_translation(callback: CallbackQuery, state: FSMContext):
"""Установить язык перевода при онбординге и завершить"""
translation_lang = callback.data.split("_")[-1] # ru | en | ja
data = await state.get_data()
lang = data.get('interface_lang', 'ru')
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if user:
await UserService.update_user_translation_language(session, user.id, translation_lang)
await state.clear()
# Приветствие с выбранными настройками
await callback.message.edit_text(t(lang, 'onboarding.complete'))
# Показываем главное меню и предлагаем тест уровня
await callback.message.answer(
t(lang, "start.new_intro", first_name=callback.from_user.first_name),
reply_markup=main_menu_keyboard(lang),
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=t(lang, 'start.offer_btn'), callback_data="offer_level_test")],
[InlineKeyboardButton(text=t(lang, 'start.skip_btn'), callback_data="skip_level_test")]
])
await callback.message.answer(t(lang, "start.offer_test"), reply_markup=keyboard)
await callback.answer()
@router.message(Command("menu"))
@@ -329,7 +440,7 @@ async def pick_theme_from_menu(callback: CallbackQuery, state: FSMContext):
level=current_level,
count=10,
learning_lang=user.learning_language,
translation_lang=user.language_interface,
translation_lang=get_user_translation_lang(user),
)
await generating.delete()
@@ -341,7 +452,7 @@ async def pick_theme_from_menu(callback: CallbackQuery, state: FSMContext):
await state.update_data(theme=theme, words=words, user_id=user.id, level=current_level)
await state.set_state(WordsStates.viewing_words)
await show_words_list(callback.message, words, theme)
await show_words_list(callback.message, words, theme, user.id)
@router.message(Command("help"))
@@ -359,7 +470,7 @@ async def offer_level_test_callback(callback: CallbackQuery, state: FSMContext):
"""Начать тест уровня из приветствия"""
from bot.handlers.level_test import start_level_test
await callback.message.delete()
await start_level_test(callback.message, state)
await start_level_test(callback.message, state, telegram_id=callback.from_user.id)
await callback.answer()

View File

@@ -10,7 +10,7 @@ from services.user_service import UserService
from services.task_service import TaskService
from services.vocabulary_service import VocabularyService
from services.ai_service import ai_service
from utils.i18n import t, get_user_lang
from utils.i18n import t, get_user_lang, get_user_translation_lang
from utils.levels import get_user_level_for_language
router = Router()
@@ -69,7 +69,7 @@ async def start_vocabulary_tasks(callback: CallbackQuery, state: FSMContext):
tasks = await TaskService.generate_mixed_tasks(
session, user.id, count=5,
learning_lang=user.learning_language,
translation_lang=user.language_interface,
translation_lang=get_user_translation_lang(user),
)
if not tasks:
@@ -122,12 +122,13 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext):
exclude_words = list(set(vocab_words + correct_task_words))
# Генерируем новые слова через AI
translation_lang = get_user_translation_lang(user)
words = await ai_service.generate_thematic_words(
theme="random everyday vocabulary",
level=level,
count=5,
learning_lang=user.learning_language,
translation_lang=user.language_interface,
translation_lang=translation_lang,
exclude_words=exclude_words if exclude_words else None,
)
@@ -138,7 +139,7 @@ async def start_new_words_tasks(callback: CallbackQuery, state: FSMContext):
# Преобразуем слова в задания
tasks = []
translate_prompt = t(lang, 'tasks.translate_to', lang_name=t(lang, f'lang.{user.language_interface}'))
translate_prompt = t(lang, 'tasks.translate_to', lang_name=t(lang, f'lang.{translation_lang}'))
for word in words:
tasks.append({
'type': 'translate',
@@ -168,6 +169,7 @@ async def show_current_task(message: Message, state: FSMContext):
data = await state.get_data()
tasks = data.get('tasks', [])
current_index = data.get('current_task_index', 0)
user_id = data.get('user_id')
if current_index >= len(tasks):
# Все задания выполнены
@@ -176,9 +178,9 @@ async def show_current_task(message: Message, state: FSMContext):
task = tasks[current_index]
# Определяем язык пользователя
# Определяем язык пользователя (берём user_id из state, т.к. message может быть от бота)
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
user = await UserService.get_user_by_id(session, user_id)
lang = (user.language_interface if user else 'ru') or 'ru'
task_text = (
@@ -349,7 +351,7 @@ async def add_task_word(callback: CallbackQuery, state: FSMContext):
word_original=word,
word_translation=translation,
source_lang=user.learning_language,
translation_lang=user.language_interface,
translation_lang=get_user_translation_lang(user),
transcription=transcription,
source=WordSource.AI_TASK
)
@@ -409,6 +411,7 @@ async def finish_tasks(message: Message, state: FSMContext):
tasks = data.get('tasks', [])
correct_count = data.get('correct_count', 0)
total_count = len(tasks)
user_id = data.get('user_id')
accuracy = int((correct_count / total_count) * 100) if total_count > 0 else 0
@@ -426,9 +429,9 @@ async def finish_tasks(message: Message, state: FSMContext):
emoji = "💪"
comment_key = 'poor'
# Язык пользователя
# Язык пользователя (берём user_id из state, т.к. message может быть от бота)
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
user = await UserService.get_user_by_id(session, user_id)
lang = (user.language_interface if user else 'ru') or 'ru'
result_text = (

View File

@@ -9,7 +9,7 @@ from database.models import WordSource
from services.user_service import UserService
from services.vocabulary_service import VocabularyService
from services.ai_service import ai_service
from utils.i18n import t, get_user_lang
from utils.i18n import t, get_user_lang, get_user_translation_lang
router = Router()
@@ -73,9 +73,9 @@ async def process_word_addition(message: Message, state: FSMContext, word: str):
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
source_lang = user.learning_language if user else 'en'
ui_lang = user.language_interface if user else 'ru'
translation_lang = get_user_translation_lang(user)
word_data = await ai_service.translate_word_with_contexts(
word, source_lang=source_lang, translation_lang=ui_lang, max_translations=3
word, source_lang=source_lang, translation_lang=translation_lang, max_translations=3
)
# Удаляем сообщение о загрузке
@@ -141,7 +141,8 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
# Получаем пользователя для языков
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
source_lang = user.learning_language if user else 'en'
ui_lang = user.language_interface if user else 'ru'
translation_lang = get_user_translation_lang(user)
ui_lang = get_user_lang(user)
# Добавляем слово в базу
new_word = await VocabularyService.add_word(
@@ -150,7 +151,7 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
word_original=word_data["word"],
word_translation=word_data["translation"],
source_lang=source_lang,
translation_lang=ui_lang,
translation_lang=translation_lang,
transcription=word_data.get("transcription"),
category=word_data.get("category"),
difficulty_level=word_data.get("difficulty"),
@@ -168,7 +169,7 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
# Получаем общее количество слов
words_count = await VocabularyService.get_words_count(session, user_id, learning_lang=user.learning_language)
lang = ui_lang or 'ru'
lang = ui_lang
await callback.message.edit_text(
t(lang, 'add.added_success', word=word_data['word'], count=words_count)

View File

@@ -9,7 +9,7 @@ from database.models import WordSource
from services.user_service import UserService
from services.vocabulary_service import VocabularyService
from services.ai_service import ai_service
from utils.i18n import t
from utils.i18n import t, get_user_lang, get_user_translation_lang
from utils.levels import get_user_level_for_language
router = Router()
@@ -64,7 +64,7 @@ async def generate_words_for_theme(message: Message, state: FSMContext, theme: s
level=current_level,
count=10,
learning_lang=user.learning_language,
translation_lang=user.language_interface,
translation_lang=get_user_translation_lang(user),
)
await generating_msg.delete()
@@ -83,15 +83,15 @@ async def generate_words_for_theme(message: Message, state: FSMContext, theme: s
await state.set_state(WordsStates.viewing_words)
# Показываем подборку
await show_words_list(message, words, theme)
await show_words_list(message, words, theme, user_id)
async def show_words_list(message: Message, words: list, theme: str):
async def show_words_list(message: Message, words: list, theme: str, user_id: int):
"""Показать список слов с кнопками для добавления"""
# Определяем язык интерфейса для заголовка/подсказок
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
user = await UserService.get_user_by_telegram_id(session, user_id)
lang = (user.language_interface if user else 'ru') or 'ru'
text = t(lang, 'words.header', theme=theme) + "\n\n"
@@ -171,9 +171,9 @@ async def add_single_word(callback: CallbackQuery, state: FSMContext):
# Добавляем слово
# Формируем examples с учётом языков
learn = user.learning_language if user else 'en'
ui = user.language_interface if user else 'ru'
translation_lang = get_user_translation_lang(user)
ex = word_data.get('example')
examples = ([{learn: ex, ui: ''}] if ex else [])
examples = ([{learn: ex, translation_lang: ''}] if ex else [])
await VocabularyService.add_word(
session=session,
@@ -181,7 +181,7 @@ async def add_single_word(callback: CallbackQuery, state: FSMContext):
word_original=word_data['word'],
word_translation=word_data['translation'],
source_lang=user.learning_language if user else None,
translation_lang=user.language_interface if user else None,
translation_lang=translation_lang,
transcription=word_data.get('transcription'),
examples=examples,
source=WordSource.SUGGESTED,
@@ -222,9 +222,9 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext):
# Добавляем слово
learn = user.learning_language if user else 'en'
ui = user.language_interface if user else 'ru'
translation_lang = get_user_translation_lang(user)
ex = word_data.get('example')
examples = ([{learn: ex, ui: ''}] if ex else [])
examples = ([{learn: ex, translation_lang: ''}] if ex else [])
await VocabularyService.add_word(
session=session,
@@ -232,7 +232,7 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext):
word_original=word_data['word'],
word_translation=word_data['translation'],
source_lang=user.learning_language if user else None,
translation_lang=user.language_interface if user else None,
translation_lang=translation_lang,
transcription=word_data.get('transcription'),
examples=examples,
source=WordSource.SUGGESTED,
@@ -241,9 +241,10 @@ async def add_all_words(callback: CallbackQuery, state: FSMContext):
)
added_count += 1
result_text = f"✅ Добавлено слов: <b>{added_count}</b>"
lang = (user.language_interface if user else 'ru') or 'ru'
result_text = t(lang, 'import.added_count', n=added_count)
if skipped_count > 0:
result_text += f"\n⚠️ Пропущено (уже в словаре): {skipped_count}"
result_text += "\n" + t(lang, 'import.skipped_count', n=skipped_count)
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer(result_text)

View File

@@ -55,8 +55,9 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True)
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False)
username: Mapped[Optional[str]] = mapped_column(String(255))
language_interface: Mapped[str] = mapped_column(String(2), default="ru") # ru/en
learning_language: Mapped[str] = mapped_column(String(2), default="en") # en
language_interface: Mapped[str] = mapped_column(String(2), default="ru") # ru/en/ja - UI language
learning_language: Mapped[str] = mapped_column(String(2), default="en") # en/ja - language being learned
translation_language: Mapped[Optional[str]] = mapped_column(String(2), default=None) # ru/en/ja - translation target (defaults to language_interface if None)
level: Mapped[LanguageLevel] = mapped_column(SQLEnum(LanguageLevel), default=LanguageLevel.A1)
levels_by_language: Mapped[Optional[dict]] = mapped_column(JSON, default=None) # {"en": "B1", "ja": "N4"}
timezone: Mapped[str] = mapped_column(String(50), default="UTC")

View File

@@ -202,6 +202,7 @@
"level_prefix": "📊 Level: ",
"learning_prefix": "🎯 Learning language: ",
"interface_prefix": "🌐 Interface language: ",
"translation_prefix": "💬 Translation language: ",
"choose": "Choose what to change:",
"close": "❌ Close",
"back": "⬅️ Back",
@@ -232,6 +233,9 @@
"lang_changed": "✅ Interface language: <b>English</b>",
"learning_title": "🎯 <b>Select learning language:</b>\n\n",
"learning_changed": "✅ Learning language: <b>{code}</b>",
"translation_title": "💬 <b>Select translation language:</b>\n\n",
"translation_desc": "Words will be translated to this language.\nThis can differ from interface language.",
"translation_changed": "✅ Translation language: <b>{lang_name}</b>",
"menu_updated": "Main menu updated ⤵️",
"lang_name": {
"ru": "🇷🇺 Русский",
@@ -290,6 +294,13 @@
"N1": "Fluent - full proficiency in Japanese"
}
},
"onboarding": {
"step2_title": "🎯 Which language do you want to learn?",
"step3_title": "💬 Which language to translate words into?",
"complete": "✅ Settings saved!",
"lang_en": "🇬🇧 English",
"lang_ja": "🇯🇵 Japanese"
},
"words": {
"generating": "🔄 Generating words for topic '{theme}'...",
"generate_failed": "❌ Failed to generate words. Please try again later.",

View File

@@ -194,6 +194,7 @@
"level_prefix": "📊 レベル: ",
"learning_prefix": "🎯 学習言語: ",
"interface_prefix": "🌐 インターフェース言語: ",
"translation_prefix": "💬 翻訳言語: ",
"choose": "変更したい項目を選択:",
"close": "❌ 閉じる",
"back": "⬅️ 戻る",
@@ -224,6 +225,9 @@
"lang_changed": "✅ インターフェース言語: <b>日本語</b>",
"learning_title": "🎯 <b>学習言語を選択:</b>\n\n",
"learning_changed": "✅ 学習言語: <b>{code}</b>",
"translation_title": "💬 <b>翻訳言語を選択:</b>\n\n",
"translation_desc": "単語はこの言語に翻訳されます。\nインターフェース言語と異なる設定が可能です。",
"translation_changed": "✅ 翻訳言語: <b>{lang_name}</b>",
"menu_updated": "メインメニューを更新しました ⤵️",
"lang_name": {
"ru": "🇷🇺 Русский",
@@ -282,6 +286,13 @@
"N1": "流暢 - 日本語を完全に習得している"
}
},
"onboarding": {
"step2_title": "🎯 どの言語を学びたいですか?",
"step3_title": "💬 どの言語に翻訳しますか?",
"complete": "✅ 設定を保存しました!",
"lang_en": "🇬🇧 英語",
"lang_ja": "🇯🇵 日本語"
},
"words": {
"generating": "🔄 テーマ『{theme}』の単語を生成中...",
"generate_failed": "❌ 単語の生成に失敗しました。後でもう一度お試しください。",

View File

@@ -202,6 +202,7 @@
"level_prefix": "📊 Уровень: ",
"learning_prefix": "🎯 Язык изучения: ",
"interface_prefix": "🌐 Язык интерфейса: ",
"translation_prefix": "💬 Язык перевода: ",
"choose": "Выбери, что хочешь изменить:",
"close": "❌ Закрыть",
"back": "⬅️ Назад",
@@ -232,6 +233,9 @@
"lang_changed": "✅ Язык интерфейса: <b>Русский</b>",
"learning_title": "🎯 <b>Выбери язык изучения:</b>\n\n",
"learning_changed": "✅ Язык изучения: <b>{code}</b>",
"translation_title": "💬 <b>Выбери язык перевода:</b>\n\n",
"translation_desc": "На этот язык будут переводиться слова.\nЭто может отличаться от языка интерфейса.",
"translation_changed": "✅ Язык перевода: <b>{lang_name}</b>",
"menu_updated": "Клавиатура обновлена ⤵️",
"lang_name": {
"ru": "🇷🇺 Русский",
@@ -290,6 +294,13 @@
"N1": "Свободный - полное владение японским языком"
}
},
"onboarding": {
"step2_title": "🎯 Какой язык хочешь изучать?",
"step3_title": "💬 На какой язык переводить слова?",
"complete": "✅ Настройки сохранены!",
"lang_en": "🇬🇧 Английский",
"lang_ja": "🇯🇵 Японский"
},
"words": {
"generating": "🔄 Генерирую подборку слов по теме '{theme}'...",
"generate_failed": "❌ Не удалось сгенерировать подборку. Попробуй позже.",

View File

@@ -0,0 +1,25 @@
"""Add translation_language field to users table
Revision ID: 20251207_translation_language
Revises: 20251206_word_translations
Create Date: 2025-12-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20251207_translation_language'
down_revision: Union[str, None] = '20251206_word_translations'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('translation_language', sa.String(2), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'translation_language')

View File

@@ -59,6 +59,23 @@ class UserService:
)
return result.scalar_one_or_none()
@staticmethod
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
"""
Получить пользователя по внутреннему ID
Args:
session: Сессия базы данных
user_id: ID пользователя в БД
Returns:
Объект пользователя или None
"""
result = await session.execute(
select(User).where(User.id == user_id)
)
return result.scalar_one_or_none()
@staticmethod
async def update_user_level(session: AsyncSession, user_id: int, level: str, language: str = None):
"""
@@ -120,3 +137,22 @@ class UserService:
if user:
user.learning_language = language
await session.commit()
@staticmethod
async def update_user_translation_language(session: AsyncSession, user_id: int, language: str):
"""
Обновить язык перевода пользователя
Args:
session: Сессия базы данных
user_id: ID пользователя
language: Новый язык перевода (ru/en/ja)
"""
result = await session.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user:
user.translation_language = language
await session.commit()

View File

@@ -36,6 +36,14 @@ def get_user_lang(user) -> str:
return (getattr(user, 'language_interface', None) if user else None) or 'ru'
def get_user_translation_lang(user) -> str:
"""Получить язык перевода (translation_language или language_interface как fallback)."""
translation_lang = getattr(user, 'translation_language', None) if user else None
if translation_lang:
return translation_lang
return get_user_lang(user)
def t(lang: str, key: str, **kwargs) -> str:
"""Translate key for given lang; fallback to ru and to key itself.