307 lines
12 KiB
Python
307 lines
12 KiB
Python
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("ℹ️ Текущая страница")
|
||
|