- Добавлены мини-истории для чтения с выбором жанра и вопросами - Кнопка показа/скрытия перевода истории - Количество вопросов берётся из настроек пользователя - Слово дня генерируется глобально в 00:00 UTC - Кнопка "Практика" открывает меню выбора режима - Убран автоматический create_all при запуске (только миграции) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
416 lines
16 KiB
Python
416 lines
16 KiB
Python
"""Handler для грамматических упражнений."""
|
||
|
||
from aiogram import Router, F
|
||
from aiogram.filters import Command
|
||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||
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 services.ai_service import ai_service
|
||
from utils.i18n import t, get_user_lang, get_user_translation_lang
|
||
from utils.levels import get_user_level_for_language
|
||
from data.grammar_rules import get_topics_for_current_level_only, get_grammar_topics_for_level
|
||
|
||
router = Router()
|
||
|
||
# Количество тем на одной странице
|
||
TOPICS_PER_PAGE = 6
|
||
|
||
|
||
class ExercisesStates(StatesGroup):
|
||
"""Состояния для грамматических упражнений."""
|
||
choosing_topic = State()
|
||
viewing_rule = State()
|
||
doing_exercise = State()
|
||
waiting_answer = State()
|
||
|
||
|
||
@router.message(Command("exercises"))
|
||
async def cmd_exercises(message: Message, state: FSMContext):
|
||
"""Обработчик команды /exercises."""
|
||
await show_exercises_menu(message, state, telegram_id=message.from_user.id)
|
||
|
||
|
||
async def show_exercises_menu(message: Message, state: FSMContext, page: int = 0, telegram_id: int = None):
|
||
"""Показать меню выбора темы упражнений."""
|
||
async with async_session_maker() as session:
|
||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||
|
||
if not user:
|
||
await message.answer(t('ru', 'common.start_first'))
|
||
return
|
||
|
||
lang = get_user_lang(user)
|
||
learning_lang = user.learning_language or 'en'
|
||
level = get_user_level_for_language(user)
|
||
|
||
# Получаем темы для текущего уровня
|
||
topics = get_topics_for_current_level_only(learning_lang, level)
|
||
|
||
if not topics:
|
||
await message.answer(t(lang, 'exercises.no_topics'))
|
||
return
|
||
|
||
# Пагинация
|
||
total_pages = (len(topics) + TOPICS_PER_PAGE - 1) // TOPICS_PER_PAGE
|
||
start_idx = page * TOPICS_PER_PAGE
|
||
end_idx = start_idx + TOPICS_PER_PAGE
|
||
page_topics = topics[start_idx:end_idx]
|
||
|
||
# Сохраняем в состоянии
|
||
await state.update_data(
|
||
topics=[t for t in topics], # Все темы
|
||
page=page,
|
||
level=level,
|
||
learning_lang=learning_lang,
|
||
user_id=user.id
|
||
)
|
||
await state.set_state(ExercisesStates.choosing_topic)
|
||
|
||
# Формируем текст
|
||
text = (
|
||
t(lang, 'exercises.title') + "\n\n" +
|
||
t(lang, 'exercises.your_level', level=level) + "\n\n" +
|
||
t(lang, 'exercises.choose_topic')
|
||
)
|
||
|
||
# Формируем клавиатуру
|
||
keyboard = []
|
||
|
||
for topic in page_topics:
|
||
# Используем русское название если интерфейс на русском, иначе английское
|
||
if lang == 'ru' and topic.get('name_ru'):
|
||
btn_text = topic['name_ru']
|
||
else:
|
||
btn_text = topic['name']
|
||
|
||
keyboard.append([
|
||
InlineKeyboardButton(
|
||
text=btn_text,
|
||
callback_data=f"exercise_topic_{topic['id']}"
|
||
)
|
||
])
|
||
|
||
# Навигация по страницам
|
||
nav_row = []
|
||
if page > 0:
|
||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"exercises_page_{page - 1}"))
|
||
if total_pages > 1:
|
||
nav_row.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="exercises_noop"))
|
||
if page < total_pages - 1:
|
||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"exercises_page_{page + 1}"))
|
||
|
||
if nav_row:
|
||
keyboard.append(nav_row)
|
||
|
||
# Кнопка закрыть
|
||
keyboard.append([
|
||
InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")
|
||
])
|
||
|
||
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||
await message.answer(text, reply_markup=reply_markup)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("exercises_page_"))
|
||
async def exercises_page_callback(callback: CallbackQuery, state: FSMContext):
|
||
"""Переключение страницы тем."""
|
||
page = int(callback.data.split("_")[-1])
|
||
await callback.answer()
|
||
|
||
# Удаляем старое сообщение и показываем новое
|
||
await callback.message.delete()
|
||
await show_exercises_menu(callback.message, state, page=page, telegram_id=callback.from_user.id)
|
||
|
||
|
||
@router.callback_query(F.data == "exercises_noop")
|
||
async def exercises_noop_callback(callback: CallbackQuery):
|
||
"""Пустой callback для кнопки с номером страницы."""
|
||
await callback.answer()
|
||
|
||
|
||
@router.callback_query(F.data == "exercises_close")
|
||
async def exercises_close_callback(callback: CallbackQuery, state: FSMContext):
|
||
"""Закрыть меню упражнений."""
|
||
await callback.message.delete()
|
||
await state.clear()
|
||
await callback.answer()
|
||
|
||
|
||
@router.callback_query(F.data.startswith("exercise_topic_"))
|
||
async def exercise_topic_callback(callback: CallbackQuery, state: FSMContext):
|
||
"""Выбор темы для упражнения - показываем правило."""
|
||
await callback.answer()
|
||
topic_id = callback.data.replace("exercise_topic_", "")
|
||
|
||
data = await state.get_data()
|
||
topics = data.get('topics', [])
|
||
level = data.get('level', 'A1')
|
||
learning_lang = data.get('learning_lang', 'en')
|
||
user_id = data.get('user_id')
|
||
|
||
# Находим выбранную тему
|
||
topic = next((tp for tp in topics if tp['id'] == topic_id), None)
|
||
if not topic:
|
||
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) if user else 'ru'
|
||
await callback.message.edit_text(t(lang, 'exercises.generate_failed'))
|
||
return
|
||
|
||
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) if user else 'ru'
|
||
|
||
# Показываем индикатор генерации правила
|
||
await callback.message.edit_text(t(lang, 'exercises.generating_rule'))
|
||
|
||
# Генерируем объяснение правила
|
||
rule_text = await ai_service.generate_grammar_rule(
|
||
topic_name=topic['name'],
|
||
topic_description=topic.get('description', ''),
|
||
level=level,
|
||
learning_lang=learning_lang,
|
||
ui_lang=lang,
|
||
user_id=user_id
|
||
)
|
||
|
||
# Сохраняем данные темы в состоянии
|
||
await state.update_data(
|
||
current_topic=topic,
|
||
rule_text=rule_text
|
||
)
|
||
await state.set_state(ExercisesStates.viewing_rule)
|
||
|
||
# Показываем правило с кнопкой "Начать упражнения"
|
||
topic_display = topic.get('name_ru', topic['name']) if lang == 'ru' else topic['name']
|
||
text = f"📖 <b>{topic_display}</b>\n\n{rule_text}"
|
||
|
||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text=t(lang, 'exercises.start_btn'), callback_data="exercises_start_tasks")],
|
||
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_back_to_topics")]
|
||
])
|
||
|
||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||
|
||
|
||
@router.callback_query(F.data == "exercises_start_tasks", ExercisesStates.viewing_rule)
|
||
async def start_exercises_callback(callback: CallbackQuery, state: FSMContext):
|
||
"""Начать упражнения после просмотра правила."""
|
||
await callback.answer()
|
||
|
||
data = await state.get_data()
|
||
topic = data.get('current_topic', {})
|
||
level = data.get('level', 'A1')
|
||
learning_lang = data.get('learning_lang', 'en')
|
||
user_id = data.get('user_id')
|
||
|
||
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) if user else 'ru'
|
||
translation_lang = get_user_translation_lang(user) if user else 'ru'
|
||
tasks_count = getattr(user, 'tasks_count', 5) or 5
|
||
|
||
# Показываем индикатор генерации
|
||
await callback.message.edit_text(t(lang, 'exercises.generating'))
|
||
|
||
# Генерируем упражнения
|
||
exercises = await ai_service.generate_grammar_exercise(
|
||
topic_id=topic.get('id', ''),
|
||
topic_name=topic.get('name', ''),
|
||
topic_description=topic.get('description', ''),
|
||
level=level,
|
||
learning_lang=learning_lang,
|
||
translation_lang=translation_lang,
|
||
count=tasks_count,
|
||
user_id=user_id
|
||
)
|
||
|
||
if not exercises:
|
||
await callback.message.edit_text(t(lang, 'exercises.generate_failed'))
|
||
return
|
||
|
||
# Сохраняем упражнения в состоянии
|
||
await state.update_data(
|
||
exercises=exercises,
|
||
current_exercise=0,
|
||
correct_count=0,
|
||
topic_name=topic.get('name', ''),
|
||
topic_name_ru=topic.get('name_ru', topic.get('name', ''))
|
||
)
|
||
await state.set_state(ExercisesStates.waiting_answer)
|
||
|
||
# Показываем первое упражнение
|
||
await show_exercise(callback.message, state, lang, edit=True)
|
||
|
||
|
||
async def show_exercise(message: Message, state: FSMContext, lang: str, edit: bool = False):
|
||
"""Показать текущее упражнение."""
|
||
data = await state.get_data()
|
||
exercises = data.get('exercises', [])
|
||
current = data.get('current_exercise', 0)
|
||
topic_name = data.get('topic_name_ru' if lang == 'ru' else 'topic_name', '')
|
||
|
||
if current >= len(exercises):
|
||
# Все упражнения завершены
|
||
await show_results(message, state, lang, edit)
|
||
return
|
||
|
||
exercise = exercises[current]
|
||
|
||
text = (
|
||
t(lang, 'exercises.task_header', topic=topic_name) + "\n\n" +
|
||
f"<b>{current + 1}/{len(exercises)}</b>\n\n" +
|
||
t(lang, 'exercises.instruction') + "\n\n" +
|
||
f"📝 {exercise.get('sentence', '')}\n\n" +
|
||
f"💬 <i>{exercise.get('translation', '')}</i>\n\n" +
|
||
f"💡 {exercise.get('hint', '')}\n\n" +
|
||
t(lang, 'exercises.write_answer')
|
||
)
|
||
|
||
# Кнопки
|
||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_back_to_topics")],
|
||
[InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")]
|
||
])
|
||
|
||
if edit:
|
||
await message.edit_text(text, reply_markup=keyboard)
|
||
else:
|
||
await message.answer(text, reply_markup=keyboard)
|
||
|
||
|
||
@router.callback_query(F.data == "exercises_back_to_topics")
|
||
async def back_to_topics_callback(callback: CallbackQuery, state: FSMContext):
|
||
"""Вернуться к выбору темы."""
|
||
await callback.answer()
|
||
await callback.message.delete()
|
||
|
||
data = await state.get_data()
|
||
page = data.get('page', 0)
|
||
|
||
await show_exercises_menu(callback.message, state, page=page, telegram_id=callback.from_user.id)
|
||
|
||
|
||
@router.message(ExercisesStates.waiting_answer)
|
||
async def process_answer(message: Message, state: FSMContext):
|
||
"""Обработка ответа пользователя."""
|
||
user_answer = message.text.strip().lower()
|
||
|
||
async with async_session_maker() as session:
|
||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||
|
||
lang = get_user_lang(user) if user else 'ru'
|
||
|
||
data = await state.get_data()
|
||
exercises = data.get('exercises', [])
|
||
current = data.get('current_exercise', 0)
|
||
correct_count = data.get('correct_count', 0)
|
||
|
||
if current >= len(exercises):
|
||
return
|
||
|
||
exercise = exercises[current]
|
||
correct_answer = exercise.get('correct_answer', '').strip().lower()
|
||
|
||
# Проверяем ответ
|
||
is_correct = user_answer == correct_answer
|
||
|
||
if is_correct:
|
||
correct_count += 1
|
||
result_text = t(lang, 'exercises.correct') + "\n\n"
|
||
else:
|
||
result_text = (
|
||
t(lang, 'exercises.incorrect') + "\n\n" +
|
||
t(lang, 'exercises.your_answer', answer=message.text) + "\n" +
|
||
t(lang, 'exercises.right_answer', answer=exercise.get('correct_answer', '')) + "\n\n"
|
||
)
|
||
|
||
# Добавляем объяснение
|
||
result_text += t(lang, 'exercises.explanation', text=exercise.get('explanation', ''))
|
||
|
||
# Обновляем состояние
|
||
await state.update_data(
|
||
current_exercise=current + 1,
|
||
correct_count=correct_count
|
||
)
|
||
|
||
# Кнопка "Следующее" или "Результаты"
|
||
if current + 1 < len(exercises):
|
||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text=t(lang, 'exercises.next_btn'), callback_data="exercises_next")]
|
||
])
|
||
else:
|
||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text=t(lang, 'exercises.results_btn'), callback_data="exercises_finish")]
|
||
])
|
||
|
||
await message.answer(result_text, reply_markup=keyboard)
|
||
|
||
|
||
@router.callback_query(F.data == "exercises_next")
|
||
async def next_exercise_callback(callback: CallbackQuery, state: FSMContext):
|
||
"""Переход к следующему упражнению."""
|
||
await callback.answer()
|
||
|
||
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) if user else 'ru'
|
||
|
||
await show_exercise(callback.message, state, lang, edit=True)
|
||
|
||
|
||
@router.callback_query(F.data == "exercises_finish")
|
||
async def finish_exercises_callback(callback: CallbackQuery, state: FSMContext):
|
||
"""Показать результаты."""
|
||
await callback.answer()
|
||
|
||
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) if user else 'ru'
|
||
|
||
# Убираем кнопку с предыдущего сообщения (оставляем фидбэк видимым)
|
||
await callback.message.edit_reply_markup(reply_markup=None)
|
||
# Показываем результаты новым сообщением
|
||
await show_results(callback.message, state, lang, edit=False)
|
||
|
||
|
||
async def show_results(message: Message, state: FSMContext, lang: str, edit: bool = False):
|
||
"""Показать результаты упражнений."""
|
||
data = await state.get_data()
|
||
exercises = data.get('exercises', [])
|
||
correct_count = data.get('correct_count', 0)
|
||
total = len(exercises)
|
||
topic_name = data.get('topic_name_ru' if lang == 'ru' else 'topic_name', '')
|
||
|
||
text = (
|
||
f"🎉 <b>{topic_name}</b>\n\n" +
|
||
t(lang, 'exercises.score', correct=correct_count, total=total)
|
||
)
|
||
|
||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_restart")],
|
||
[InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")]
|
||
])
|
||
|
||
if edit:
|
||
await message.edit_text(text, reply_markup=keyboard)
|
||
else:
|
||
await message.answer(text, reply_markup=keyboard)
|
||
|
||
await state.clear()
|
||
|
||
|
||
@router.callback_query(F.data == "exercises_restart")
|
||
async def restart_exercises_callback(callback: CallbackQuery, state: FSMContext):
|
||
"""Вернуться к выбору темы после завершения."""
|
||
await callback.answer()
|
||
await callback.message.delete()
|
||
await show_exercises_menu(callback.message, state, page=0, telegram_id=callback.from_user.id)
|