feat: мини-истории, слово дня, меню практики
- Добавлены мини-истории для чтения с выбором жанра и вопросами - Кнопка показа/скрытия перевода истории - Количество вопросов берётся из настроек пользователя - Слово дня генерируется глобально в 00:00 UTC - Кнопка "Практика" открывает меню выбора режима - Убран автоматический create_all при запуске (только миграции) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
415
bot/handlers/exercises.py
Normal file
415
bot/handlers/exercises.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user