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"📖 {title}\n\n"
f"✍️ Авторы: {authors}\n"
f"🏷 Жанры: {genres}\n"
f"{stars} Рейтинг: {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("ℹ️ Текущая страница")