initial commit
This commit is contained in:
5
handlers/__init__.py
Normal file
5
handlers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Модуль обработчиков событий бота"""
|
||||
from handlers import auth, books, favorites
|
||||
|
||||
__all__ = ['auth', 'books', 'favorites']
|
||||
|
||||
340
handlers/auth.py
Normal file
340
handlers/auth.py
Normal file
@@ -0,0 +1,340 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import CommandStart
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from database.models import User
|
||||
from database.connection import async_session_maker
|
||||
from utils.states import RegistrationStates, LoginStates
|
||||
from keyboards.inline import get_auth_menu, get_main_menu, get_back_to_menu_keyboard
|
||||
import bcrypt
|
||||
import logging
|
||||
import re
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_start(message: Message, state: FSMContext):
|
||||
"""Обработчик команды /start"""
|
||||
await state.clear()
|
||||
|
||||
telegram_id = message.from_user.id
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
# Пользователь авторизован
|
||||
await message.answer(
|
||||
f"👋 Добро пожаловать, {user.first_name or user.username}!\n\n"
|
||||
"📚 Выберите действие:",
|
||||
reply_markup=get_main_menu()
|
||||
)
|
||||
else:
|
||||
# Пользователь не авторизован
|
||||
await message.answer(
|
||||
"👋 Добро пожаловать в бот 'Что почитать'!\n\n"
|
||||
"📚 Здесь вы можете просматривать книги с описаниями, обложками и рейтингами.\n\n"
|
||||
"Пожалуйста, войдите или зарегистрируйтесь:",
|
||||
reply_markup=get_auth_menu()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в cmd_start: {e}")
|
||||
await message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_menu")
|
||||
async def show_main_menu(callback: CallbackQuery, state: FSMContext):
|
||||
"""Показать главное меню"""
|
||||
await state.clear()
|
||||
telegram_id = callback.from_user.id
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
await callback.message.edit_text(
|
||||
f"👋 Добро пожаловать, {user.first_name or user.username}!\n\n"
|
||||
"📚 Выберите действие:",
|
||||
reply_markup=get_main_menu()
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"👋 Добро пожаловать в бот 'Что почитать'!\n\n"
|
||||
"Пожалуйста, войдите или зарегистрируйтесь:",
|
||||
reply_markup=get_auth_menu()
|
||||
)
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_main_menu: {e}")
|
||||
await callback.answer("❌ Ошибка", show_alert=True)
|
||||
|
||||
|
||||
# === РЕГИСТРАЦИЯ ===
|
||||
|
||||
@router.callback_query(F.data == "register")
|
||||
async def start_registration(callback: CallbackQuery, state: FSMContext):
|
||||
"""Начало регистрации"""
|
||||
await state.set_state(RegistrationStates.waiting_email)
|
||||
await callback.message.edit_text(
|
||||
"📝 Регистрация\n\n"
|
||||
"Введите ваш email:"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.message(RegistrationStates.waiting_email)
|
||||
async def process_registration_email(message: Message, state: FSMContext):
|
||||
"""Обработка email при регистрации"""
|
||||
email = message.text.strip().lower()
|
||||
|
||||
# Валидация email
|
||||
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
if not re.match(email_pattern, email):
|
||||
await message.answer("❌ Неверный формат email. Попробуйте снова:")
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Проверка уникальности email
|
||||
result = await session.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user:
|
||||
await message.answer(
|
||||
"❌ Этот email уже зарегистрирован.\n\n"
|
||||
"Используйте другой email или войдите в систему."
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.update_data(email=email)
|
||||
await state.set_state(RegistrationStates.waiting_username)
|
||||
await message.answer("✅ Email принят.\n\nТеперь введите желаемое имя пользователя (username):")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке email: {e}")
|
||||
await message.answer("❌ Произошла ошибка. Попробуйте снова.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(RegistrationStates.waiting_username)
|
||||
async def process_registration_username(message: Message, state: FSMContext):
|
||||
"""Обработка username при регистрации"""
|
||||
username = message.text.strip()
|
||||
|
||||
# Валидация username
|
||||
if len(username) < 3 or len(username) > 150:
|
||||
await message.answer("❌ Имя пользователя должно быть от 3 до 150 символов. Попробуйте снова:")
|
||||
return
|
||||
|
||||
if not re.match(r'^[a-zA-Z0-9_]+$', username):
|
||||
await message.answer("❌ Имя пользователя может содержать только буквы, цифры и подчеркивание. Попробуйте снова:")
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Проверка уникальности username
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user:
|
||||
await message.answer("❌ Это имя пользователя уже занято. Попробуйте другое:")
|
||||
return
|
||||
|
||||
await state.update_data(username=username)
|
||||
await state.set_state(RegistrationStates.waiting_password)
|
||||
await message.answer(
|
||||
"✅ Имя пользователя принято.\n\n"
|
||||
"Теперь введите пароль (минимум 8 символов):"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке username: {e}")
|
||||
await message.answer("❌ Произошла ошибка. Попробуйте снова.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(RegistrationStates.waiting_password)
|
||||
async def process_registration_password(message: Message, state: FSMContext):
|
||||
"""Обработка пароля и завершение регистрации"""
|
||||
password = message.text.strip()
|
||||
|
||||
# Валидация пароля
|
||||
if len(password) < 8:
|
||||
await message.answer("❌ Пароль должен содержать минимум 8 символов. Попробуйте снова:")
|
||||
return
|
||||
|
||||
try:
|
||||
# Удаляем сообщение с паролем из чата
|
||||
await message.delete()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
email = data['email']
|
||||
username = data['username']
|
||||
|
||||
# Хешируем пароль
|
||||
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Создаем нового пользователя
|
||||
new_user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
password=hashed_password,
|
||||
telegram_id=message.from_user.id,
|
||||
first_name=message.from_user.first_name or "",
|
||||
last_name=message.from_user.last_name or "",
|
||||
is_active=True
|
||||
)
|
||||
|
||||
session.add(new_user)
|
||||
await session.commit()
|
||||
|
||||
await message.answer(
|
||||
f"✅ Регистрация успешно завершена!\n\n"
|
||||
f"👤 Ваш username: {username}\n"
|
||||
f"📧 Email: {email}\n\n"
|
||||
"Добро пожаловать в бот 'Что почитать'!",
|
||||
reply_markup=get_main_menu()
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании пользователя: {e}")
|
||||
await message.answer("❌ Произошла ошибка при регистрации. Попробуйте снова.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
# === ВХОД ===
|
||||
|
||||
@router.callback_query(F.data == "login")
|
||||
async def start_login(callback: CallbackQuery, state: FSMContext):
|
||||
"""Начало входа"""
|
||||
await state.set_state(LoginStates.waiting_email)
|
||||
await callback.message.edit_text(
|
||||
"🔐 Вход в систему\n\n"
|
||||
"Введите ваш email:"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.message(LoginStates.waiting_email)
|
||||
async def process_login_email(message: Message, state: FSMContext):
|
||||
"""Обработка email при входе"""
|
||||
email = message.text.strip().lower()
|
||||
|
||||
await state.update_data(email=email)
|
||||
await state.set_state(LoginStates.waiting_password)
|
||||
await message.answer("Теперь введите ваш пароль:")
|
||||
|
||||
|
||||
@router.message(LoginStates.waiting_password)
|
||||
async def process_login_password(message: Message, state: FSMContext):
|
||||
"""Обработка пароля и завершение входа"""
|
||||
password = message.text.strip()
|
||||
|
||||
try:
|
||||
# Удаляем сообщение с паролем
|
||||
await message.delete()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
email = data['email']
|
||||
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
await message.answer("❌ Пользователь с таким email не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем пароль
|
||||
if not bcrypt.checkpw(password.encode('utf-8'), user.password.encode('utf-8')):
|
||||
await message.answer("❌ Неверный пароль.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, не привязан ли уже другой Telegram аккаунт
|
||||
if user.telegram_id and user.telegram_id != message.from_user.id:
|
||||
await message.answer("❌ Этот аккаунт уже привязан к другому Telegram профилю.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Обновляем telegram_id
|
||||
user.telegram_id = message.from_user.id
|
||||
await session.commit()
|
||||
|
||||
await message.answer(
|
||||
f"✅ Вход выполнен успешно!\n\n"
|
||||
f"👋 Добро пожаловать, {user.first_name or user.username}!",
|
||||
reply_markup=get_main_menu()
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при входе: {e}")
|
||||
await message.answer("❌ Произошла ошибка при входе. Попробуйте снова.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
# === ПРОФИЛЬ ===
|
||||
|
||||
@router.callback_query(F.data == "profile")
|
||||
async def show_profile(callback: CallbackQuery):
|
||||
"""Показать профиль пользователя"""
|
||||
telegram_id = callback.from_user.id
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
await callback.answer("⛔ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
profile_text = (
|
||||
f"👤 Ваш профиль\n\n"
|
||||
f"🆔 Username: {user.username}\n"
|
||||
f"📧 Email: {user.email}\n"
|
||||
f"👤 Имя: {user.first_name or 'Не указано'}\n"
|
||||
f"📅 Дата регистрации: {user.date_joined.strftime('%d.%m.%Y')}"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
profile_text,
|
||||
reply_markup=get_back_to_menu_keyboard()
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_profile: {e}")
|
||||
await callback.answer("❌ Ошибка", show_alert=True)
|
||||
|
||||
306
handlers/books.py
Normal file
306
handlers/books.py
Normal file
@@ -0,0 +1,306 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import CallbackQuery, InputMediaPhoto
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text, func
|
||||
from database.models import Book, Author, Genre, Favorite, User
|
||||
from database.connection import async_session_maker
|
||||
from keyboards.inline import get_book_keyboard, get_pagination_keyboard, get_genres_keyboard, get_main_menu
|
||||
import logging
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BOOKS_PER_PAGE = 10
|
||||
|
||||
|
||||
async def get_books_with_details(session: AsyncSession, offset: int = 0, limit: int = 10, genre_id: int = None):
|
||||
"""Получить книги с авторами и жанрами"""
|
||||
try:
|
||||
if genre_id:
|
||||
query = text("""
|
||||
SELECT DISTINCT
|
||||
b.id, b.title, b.description, b.cover_url,
|
||||
b.average_rating, b.rating_count,
|
||||
STRING_AGG(DISTINCT a.name, ', ') as authors,
|
||||
STRING_AGG(DISTINCT g.name, ', ') as genres
|
||||
FROM books_book b
|
||||
LEFT JOIN books_book_authors ba ON b.id = ba.book_id
|
||||
LEFT JOIN books_author a ON ba.author_id = a.id
|
||||
LEFT JOIN books_book_genres bg ON b.id = bg.book_id
|
||||
LEFT JOIN books_genre g ON bg.genre_id = g.id
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.id IN (
|
||||
SELECT book_id FROM books_book_genres WHERE genre_id = :genre_id
|
||||
)
|
||||
GROUP BY b.id
|
||||
ORDER BY b.created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
result = await session.execute(query, {"limit": limit, "offset": offset, "genre_id": genre_id})
|
||||
else:
|
||||
query = text("""
|
||||
SELECT
|
||||
b.id, b.title, b.description, b.cover_url,
|
||||
b.average_rating, b.rating_count,
|
||||
STRING_AGG(DISTINCT a.name, ', ') as authors,
|
||||
STRING_AGG(DISTINCT g.name, ', ') as genres
|
||||
FROM books_book b
|
||||
LEFT JOIN books_book_authors ba ON b.id = ba.book_id
|
||||
LEFT JOIN books_author a ON ba.author_id = a.id
|
||||
LEFT JOIN books_book_genres bg ON b.id = bg.book_id
|
||||
LEFT JOIN books_genre g ON bg.genre_id = g.id
|
||||
WHERE b.deleted_at IS NULL
|
||||
GROUP BY b.id
|
||||
ORDER BY b.created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
result = await session.execute(query, {"limit": limit, "offset": offset})
|
||||
|
||||
return result.mappings().all()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении книг: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_total_books_count(session: AsyncSession, genre_id: int = None) -> int:
|
||||
"""Получить общее количество книг"""
|
||||
try:
|
||||
if genre_id:
|
||||
query = text("""
|
||||
SELECT COUNT(DISTINCT b.id)
|
||||
FROM books_book b
|
||||
JOIN books_book_genres bg ON b.id = bg.book_id
|
||||
WHERE b.deleted_at IS NULL AND bg.genre_id = :genre_id
|
||||
""")
|
||||
result = await session.execute(query, {"genre_id": genre_id})
|
||||
else:
|
||||
query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM books_book
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
result = await session.execute(query)
|
||||
|
||||
return result.scalar() or 0
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при подсчете книг: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def is_book_favorite(session: AsyncSession, user_id: int, book_id: int) -> bool:
|
||||
"""Проверить, находится ли книга в избранном"""
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Favorite).where(
|
||||
Favorite.user_id == user_id,
|
||||
Favorite.book_id == book_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке избранного: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def format_book_message(book: dict, is_favorite: bool = False) -> str:
|
||||
"""Форматировать сообщение о книге"""
|
||||
title = book['title']
|
||||
authors = book['authors'] or 'Не указано'
|
||||
genres = book['genres'] or 'Не указано'
|
||||
rating = float(book['average_rating']) if book['average_rating'] else 0.0
|
||||
rating_count = book['rating_count'] or 0
|
||||
description = book['description'] or 'Описание отсутствует'
|
||||
|
||||
# Обрезаем описание если оно слишком длинное
|
||||
if len(description) > 500:
|
||||
description = description[:497] + "..."
|
||||
|
||||
# Формируем звездочки для рейтинга
|
||||
stars = "⭐" * int(rating)
|
||||
|
||||
message = (
|
||||
f"📖 <b>{title}</b>\n\n"
|
||||
f"✍️ <b>Авторы:</b> {authors}\n"
|
||||
f"🏷 <b>Жанры:</b> {genres}\n"
|
||||
f"{stars} <b>Рейтинг:</b> {rating:.2f} ({rating_count} оценок)\n\n"
|
||||
f"{description}"
|
||||
)
|
||||
|
||||
if is_favorite:
|
||||
message = "❤️ " + message
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@router.callback_query(F.data == "books_all")
|
||||
async def show_all_books(callback: CallbackQuery, state: FSMContext):
|
||||
"""Показать все книги (первая страница)"""
|
||||
await state.clear()
|
||||
await show_books_page(callback, 0)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("books_page:"))
|
||||
async def handle_books_pagination(callback: CallbackQuery):
|
||||
"""Обработка пагинации книг"""
|
||||
page = int(callback.data.split(":")[1])
|
||||
await show_books_page(callback, page)
|
||||
|
||||
|
||||
async def show_books_page(callback: CallbackQuery, page: int, genre_id: int = None):
|
||||
"""Показать страницу с книгами"""
|
||||
telegram_id = callback.from_user.id
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Получаем пользователя
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
await callback.answer("⛔ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем общее количество книг
|
||||
total_books = await get_total_books_count(session, genre_id)
|
||||
|
||||
if total_books == 0:
|
||||
await callback.message.edit_text(
|
||||
"📚 Книги не найдены",
|
||||
reply_markup=get_main_menu()
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
total_pages = (total_books + BOOKS_PER_PAGE - 1) // BOOKS_PER_PAGE
|
||||
|
||||
# Проверяем корректность страницы
|
||||
if page < 0 or page >= total_pages:
|
||||
await callback.answer("❌ Страница не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем книги для страницы
|
||||
books = await get_books_with_details(
|
||||
session,
|
||||
offset=page * BOOKS_PER_PAGE,
|
||||
limit=BOOKS_PER_PAGE,
|
||||
genre_id=genre_id
|
||||
)
|
||||
|
||||
if not books:
|
||||
await callback.answer("❌ Книги не найдены", show_alert=True)
|
||||
return
|
||||
|
||||
# Показываем первую книгу на странице
|
||||
book = books[0]
|
||||
is_favorite = await is_book_favorite(session, user.id, book['id'])
|
||||
|
||||
message_text = format_book_message(book, is_favorite)
|
||||
keyboard = get_book_keyboard(book['id'], is_favorite, page, total_pages)
|
||||
|
||||
# Пытаемся отправить с картинкой
|
||||
if book['cover_url']:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.edit_media(
|
||||
media=InputMediaPhoto(
|
||||
media=book['cover_url'],
|
||||
caption=message_text,
|
||||
parse_mode="HTML"
|
||||
),
|
||||
reply_markup=keyboard
|
||||
)
|
||||
else:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
photo=book['cover_url'],
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось загрузить обложку: {e}")
|
||||
await callback.message.edit_text(
|
||||
"🖼 [Обложка недоступна]\n\n" + message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_books_page: {e}")
|
||||
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||
|
||||
|
||||
# === ПОИСК ПО ЖАНРУ ===
|
||||
|
||||
@router.callback_query(F.data == "search_genre")
|
||||
async def show_genres(callback: CallbackQuery):
|
||||
"""Показать список жанров"""
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(Genre.id, Genre.name).order_by(Genre.name)
|
||||
)
|
||||
genres = result.all()
|
||||
|
||||
if not genres:
|
||||
await callback.message.edit_text(
|
||||
"📚 Жанры не найдены",
|
||||
reply_markup=get_main_menu()
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔍 Выберите жанр для поиска книг:",
|
||||
reply_markup=get_genres_keyboard(genres)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_genres: {e}")
|
||||
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("genre:"))
|
||||
async def show_books_by_genre(callback: CallbackQuery, state: FSMContext):
|
||||
"""Показать книги выбранного жанра"""
|
||||
genre_id = int(callback.data.split(":")[1])
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Получаем название жанра
|
||||
result = await session.execute(
|
||||
select(Genre).where(Genre.id == genre_id)
|
||||
)
|
||||
genre = result.scalar_one_or_none()
|
||||
|
||||
if not genre:
|
||||
await callback.answer("❌ Жанр не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Сохраняем genre_id в состоянии для пагинации
|
||||
await state.update_data(genre_id=genre_id)
|
||||
|
||||
await show_books_page(callback, 0, genre_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_books_by_genre: {e}")
|
||||
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "page_info")
|
||||
async def page_info_handler(callback: CallbackQuery):
|
||||
"""Обработчик для информационной кнопки страницы"""
|
||||
await callback.answer("ℹ️ Текущая страница")
|
||||
|
||||
252
handlers/favorites.py
Normal file
252
handlers/favorites.py
Normal file
@@ -0,0 +1,252 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import CallbackQuery, InputMediaPhoto
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text, delete
|
||||
from database.models import Book, Favorite, User
|
||||
from database.connection import async_session_maker
|
||||
from keyboards.inline import get_book_keyboard, get_main_menu
|
||||
from handlers.books import format_book_message, is_book_favorite
|
||||
import logging
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("add_fav:"))
|
||||
async def add_to_favorites(callback: CallbackQuery):
|
||||
"""Добавить книгу в избранное"""
|
||||
book_id = int(callback.data.split(":")[1])
|
||||
telegram_id = callback.from_user.id
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Получаем пользователя
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
await callback.answer("⛔ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, существует ли книга
|
||||
result = await session.execute(
|
||||
select(Book).where(Book.id == book_id, Book.deleted_at.is_(None))
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
await callback.answer("❌ Книга не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, не добавлена ли уже
|
||||
result = await session.execute(
|
||||
select(Favorite).where(
|
||||
Favorite.user_id == user.id,
|
||||
Favorite.book_id == book_id
|
||||
)
|
||||
)
|
||||
existing_favorite = result.scalar_one_or_none()
|
||||
|
||||
if existing_favorite:
|
||||
await callback.answer("ℹ️ Книга уже в избранном", show_alert=True)
|
||||
return
|
||||
|
||||
# Добавляем в избранное
|
||||
new_favorite = Favorite(
|
||||
user_id=user.id,
|
||||
book_id=book_id
|
||||
)
|
||||
session.add(new_favorite)
|
||||
await session.commit()
|
||||
|
||||
# Обновляем клавиатуру
|
||||
try:
|
||||
current_markup = callback.message.reply_markup
|
||||
# Извлекаем номер страницы из существующей клавиатуры
|
||||
page = 0
|
||||
total_pages = 1
|
||||
|
||||
if current_markup and current_markup.inline_keyboard:
|
||||
for row in current_markup.inline_keyboard:
|
||||
for button in row:
|
||||
if button.callback_data and "books_page:" in button.callback_data:
|
||||
parts = button.callback_data.split(":")
|
||||
if len(parts) > 1:
|
||||
page = int(parts[1])
|
||||
elif button.text and "/" in button.text:
|
||||
try:
|
||||
pages_info = button.text.split("/")
|
||||
page = int(pages_info[0]) - 1
|
||||
total_pages = int(pages_info[1])
|
||||
except:
|
||||
pass
|
||||
|
||||
new_keyboard = get_book_keyboard(book_id, True, page, total_pages)
|
||||
await callback.message.edit_reply_markup(reply_markup=new_keyboard)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось обновить клавиатуру: {e}")
|
||||
|
||||
await callback.answer("✅ Книга добавлена в избранное!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении в избранное: {e}")
|
||||
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("remove_fav:"))
|
||||
async def remove_from_favorites(callback: CallbackQuery):
|
||||
"""Удалить книгу из избранного"""
|
||||
book_id = int(callback.data.split(":")[1])
|
||||
telegram_id = callback.from_user.id
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Получаем пользователя
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
await callback.answer("⛔ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Удаляем из избранного
|
||||
await session.execute(
|
||||
delete(Favorite).where(
|
||||
Favorite.user_id == user.id,
|
||||
Favorite.book_id == book_id
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Обновляем клавиатуру
|
||||
try:
|
||||
current_markup = callback.message.reply_markup
|
||||
page = 0
|
||||
total_pages = 1
|
||||
|
||||
if current_markup and current_markup.inline_keyboard:
|
||||
for row in current_markup.inline_keyboard:
|
||||
for button in row:
|
||||
if button.callback_data and "books_page:" in button.callback_data:
|
||||
parts = button.callback_data.split(":")
|
||||
if len(parts) > 1:
|
||||
page = int(parts[1])
|
||||
elif button.text and "/" in button.text:
|
||||
try:
|
||||
pages_info = button.text.split("/")
|
||||
page = int(pages_info[0]) - 1
|
||||
total_pages = int(pages_info[1])
|
||||
except:
|
||||
pass
|
||||
|
||||
new_keyboard = get_book_keyboard(book_id, False, page, total_pages)
|
||||
await callback.message.edit_reply_markup(reply_markup=new_keyboard)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось обновить клавиатуру: {e}")
|
||||
|
||||
await callback.answer("❌ Книга удалена из избранного")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении из избранного: {e}")
|
||||
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "favorites")
|
||||
async def show_favorites(callback: CallbackQuery):
|
||||
"""Показать избранные книги"""
|
||||
telegram_id = callback.from_user.id
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Получаем пользователя
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
await callback.answer("⛔ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем избранные книги с полной информацией
|
||||
query = text("""
|
||||
SELECT
|
||||
b.id, b.title, b.description, b.cover_url,
|
||||
b.average_rating, b.rating_count,
|
||||
STRING_AGG(DISTINCT a.name, ', ') as authors,
|
||||
STRING_AGG(DISTINCT g.name, ', ') as genres
|
||||
FROM books_favorite f
|
||||
JOIN books_book b ON f.book_id = b.id
|
||||
LEFT JOIN books_book_authors ba ON b.id = ba.book_id
|
||||
LEFT JOIN books_author a ON ba.author_id = a.id
|
||||
LEFT JOIN books_book_genres bg ON b.id = bg.book_id
|
||||
LEFT JOIN books_genre g ON bg.genre_id = g.id
|
||||
WHERE f.user_id = :user_id AND b.deleted_at IS NULL
|
||||
GROUP BY b.id, f.created_at
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
result = await session.execute(query, {"user_id": user.id})
|
||||
favorites = result.mappings().all()
|
||||
|
||||
if not favorites:
|
||||
await callback.message.edit_text(
|
||||
"⭐ У вас пока нет избранных книг.\n\n"
|
||||
"Добавьте книги в избранное, чтобы быстро находить их здесь!",
|
||||
reply_markup=get_main_menu()
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Показываем первую избранную книгу
|
||||
book = favorites[0]
|
||||
message_text = format_book_message(book, is_favorite=True)
|
||||
|
||||
# Создаем клавиатуру (всегда с кнопкой удаления из избранного)
|
||||
keyboard = get_book_keyboard(book['id'], True, 0, 1)
|
||||
|
||||
# Отправляем с картинкой если есть
|
||||
if book['cover_url']:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.edit_media(
|
||||
media=InputMediaPhoto(
|
||||
media=book['cover_url'],
|
||||
caption=message_text,
|
||||
parse_mode="HTML"
|
||||
),
|
||||
reply_markup=keyboard
|
||||
)
|
||||
else:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
photo=book['cover_url'],
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось загрузить обложку: {e}")
|
||||
await callback.message.edit_text(
|
||||
"🖼 [Обложка недоступна]\n\n" + message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_favorites: {e}")
|
||||
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||
|
||||
Reference in New Issue
Block a user