initial commit
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
BOT_TOKEN=
|
||||||
|
DATABASE_URL=
|
||||||
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
459
CLAUDE.md
Normal file
459
CLAUDE.md
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
```markdown
|
||||||
|
# Telegram Bot "Что почитать" - Техническое задание
|
||||||
|
|
||||||
|
## Описание проекта
|
||||||
|
Telegram-бот для просмотра книг с описаниями, обложками и рейтингами. Бот является интеграцией к существующему веб-сервису на Django с общей PostgreSQL базой данных.
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
- **Python 3.10+**
|
||||||
|
- **aiogram 3.x** - асинхронный фреймворк для Telegram Bot API [web:1][web:3]
|
||||||
|
- **asyncpg** или **psycopg2** - для работы с PostgreSQL [web:6]
|
||||||
|
- **SQLAlchemy 2.0** (асинхронный режим) - ORM для работы с БД
|
||||||
|
- **python-dotenv** - для хранения конфигурации
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
```
|
||||||
|
|
||||||
|
telegram_bot/
|
||||||
|
├── .env \# Переменные окружения
|
||||||
|
├── .env.example \# Пример конфигурации
|
||||||
|
├── requirements.txt \# Зависимости
|
||||||
|
├── main.py \# Точка входа
|
||||||
|
├── config.py \# Конфигурация
|
||||||
|
├── database/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── connection.py \# Подключение к БД
|
||||||
|
│ └── models.py \# SQLAlchemy модели
|
||||||
|
├── handlers/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── auth.py \# Авторизация/регистрация
|
||||||
|
│ ├── books.py \# Просмотр книг
|
||||||
|
│ └── favorites.py \# Избранное
|
||||||
|
├── keyboards/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── inline.py \# Inline-клавиатуры[^1][^2]
|
||||||
|
├── middlewares/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── auth.py \# Проверка авторизации
|
||||||
|
└── utils/
|
||||||
|
├── __init__.py
|
||||||
|
└── states.py \# FSM состояния
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Схема базы данных (уже существует)
|
||||||
|
|
||||||
|
### Таблица: users (из Django)
|
||||||
|
- `id` - PRIMARY KEY
|
||||||
|
- `username` - уникальное имя пользователя
|
||||||
|
- `email` - уникальный email (используется для входа)
|
||||||
|
- `password` - хешированный пароль
|
||||||
|
- `telegram_id` - BigInteger, уникальный, nullable
|
||||||
|
- `first_name`, `last_name` - имя и фамилия
|
||||||
|
- остальные поля Django User
|
||||||
|
|
||||||
|
### Таблица: books_book
|
||||||
|
- `id` - PRIMARY KEY
|
||||||
|
- `title` - название книги (max 500)
|
||||||
|
- `description` - описание (TEXT)
|
||||||
|
- `cover_url` - ссылка на обложку (URL)
|
||||||
|
- `average_rating` - средний рейтинг (Decimal 0.00-5.00)
|
||||||
|
- `rating_count` - количество оценок
|
||||||
|
- `language` - язык книги
|
||||||
|
- `created_at`, `updated_at` - временные метки
|
||||||
|
|
||||||
|
### Таблица: books_author
|
||||||
|
- `id` - PRIMARY KEY
|
||||||
|
- `name` - имя автора
|
||||||
|
|
||||||
|
### Таблица: books_genre
|
||||||
|
- `id` - PRIMARY KEY
|
||||||
|
- `name` - название жанра
|
||||||
|
|
||||||
|
### Таблица: books_book_authors (ManyToMany)
|
||||||
|
- `book_id` - FK на books_book
|
||||||
|
- `author_id` - FK на books_author
|
||||||
|
|
||||||
|
### Таблица: books_book_genres (ManyToMany)
|
||||||
|
- `book_id` - FK на books_book
|
||||||
|
- `genre_id` - FK на books_genre
|
||||||
|
|
||||||
|
### Таблица: books_favorite
|
||||||
|
- `id` - PRIMARY KEY
|
||||||
|
- `user_id` - FK на users
|
||||||
|
- `book_id` - FK на books_book
|
||||||
|
- `created_at` - дата добавления
|
||||||
|
- UNIQUE(user_id, book_id)
|
||||||
|
|
||||||
|
## Функциональные требования
|
||||||
|
|
||||||
|
### 1. Авторизация и регистрация
|
||||||
|
**Команда:** `/start`
|
||||||
|
|
||||||
|
**Логика:**
|
||||||
|
- Проверить, есть ли у пользователя `telegram_id` в таблице `users`
|
||||||
|
- Если да → авторизован, показать главное меню
|
||||||
|
- Если нет → показать кнопки "Войти" и "Зарегистрироваться"
|
||||||
|
|
||||||
|
**Регистрация:**
|
||||||
|
1. Кнопка "Зарегистрироваться" → запросить email
|
||||||
|
2. Проверить уникальность email в БД
|
||||||
|
3. Запросить username (проверить уникальность)
|
||||||
|
4. Запросить пароль (минимум 8 символов)
|
||||||
|
5. Создать запись в таблице `users` с `telegram_id = message.from_user.id`
|
||||||
|
6. Хешировать пароль через `bcrypt` или `django.contrib.auth.hashers`
|
||||||
|
|
||||||
|
**Вход:**
|
||||||
|
1. Кнопка "Войти" → запросить email
|
||||||
|
2. Запросить пароль
|
||||||
|
3. Проверить credentials в БД
|
||||||
|
4. Если успешно → обновить `telegram_id` для этого пользователя
|
||||||
|
5. Показать главное меню
|
||||||
|
|
||||||
|
### 2. Главное меню (после авторизации)
|
||||||
|
Inline-клавиатура [web:7]:
|
||||||
|
- 📚 Все книги
|
||||||
|
- ⭐ Избранное
|
||||||
|
- 🔍 Поиск по жанру
|
||||||
|
- 👤 Мой профиль
|
||||||
|
|
||||||
|
### 3. Просмотр всех книг
|
||||||
|
**Запрос к БД:**
|
||||||
|
```
|
||||||
|
|
||||||
|
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 10 OFFSET ?
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Формат вывода:**
|
||||||
|
```
|
||||||
|
|
||||||
|
📖 [Название книги]
|
||||||
|
|
||||||
|
✍️ Авторы: [список через запятую]
|
||||||
|
🏷 Жанры: [список через запятую]
|
||||||
|
⭐ Рейтинг: X.XX (Y оценок)
|
||||||
|
|
||||||
|
[Описание книги, максимум 200 символов]
|
||||||
|
|
||||||
|
[Картинка по cover_url]
|
||||||
|
|
||||||
|
Кнопки: [❤️ В избранное] [➡️ След. страница]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пагинация:** по 10 книг на страницу, кнопки "⬅️ Назад" / "➡️ Вперед"
|
||||||
|
|
||||||
|
### 4. Избранное
|
||||||
|
**Запрос к БД:**
|
||||||
|
```
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
b.id, b.title, b.cover_url, b.average_rating,
|
||||||
|
STRING_AGG(DISTINCT a.name, ', ') as authors
|
||||||
|
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
|
||||||
|
WHERE f.user_id = ? AND b.deleted_at IS NULL
|
||||||
|
GROUP BY b.id, f.created_at
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Действия:**
|
||||||
|
- Показать список избранных книг (формат как в п.3)
|
||||||
|
- Кнопка "❌ Удалить из избранного" → DELETE FROM books_favorite
|
||||||
|
|
||||||
|
### 5. Добавление в избранное
|
||||||
|
**Callback:** `add_fav:{book_id}`
|
||||||
|
|
||||||
|
**Запрос:**
|
||||||
|
```
|
||||||
|
|
||||||
|
INSERT INTO books_favorite (user_id, book_id, created_at)
|
||||||
|
VALUES (?, ?, NOW())
|
||||||
|
ON CONFLICT (user_id, book_id) DO NOTHING
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ответ:** "✅ Книга добавлена в избранное!"
|
||||||
|
|
||||||
|
### 6. Поиск по жанру
|
||||||
|
1. Получить список жанров: `SELECT id, name FROM books_genre ORDER BY name`
|
||||||
|
2. Показать inline-клавиатуру с жанрами (по 2 в строке) [web:7]
|
||||||
|
3. После выбора жанра → фильтровать книги:
|
||||||
|
```
|
||||||
|
|
||||||
|
SELECT DISTINCT b.* FROM books_book b
|
||||||
|
JOIN books_book_genres bg ON b.id = bg.book_id
|
||||||
|
WHERE bg.genre_id = ? AND b.deleted_at IS NULL
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Технические требования
|
||||||
|
|
||||||
|
### 1. Подключение к базе данных
|
||||||
|
**Файл:** `database/connection.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from config import DATABASE_URL
|
||||||
|
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def get_session():
|
||||||
|
async with async_session() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Конфигурация .env:**
|
||||||
|
```
|
||||||
|
|
||||||
|
BOT_TOKEN=your_bot_token_here
|
||||||
|
DATABASE_URL=postgresql+asyncpg://user:password@host:port/dbname
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Middleware для проверки авторизации [web:3]
|
||||||
|
**Файл:** `middlewares/auth.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
|
||||||
|
class AuthMiddleware(BaseMiddleware):
|
||||||
|
async def __call__(self, handler, event, data):
|
||||||
|
\# Пропускать команду /start
|
||||||
|
if isinstance(event, Message) and event.text == '/start':
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
# Проверить telegram_id в БД
|
||||||
|
user = await get_user_by_telegram_id(event.from_user.id)
|
||||||
|
if not user:
|
||||||
|
await event.answer("⛔ Сначала авторизуйтесь через /start")
|
||||||
|
return
|
||||||
|
|
||||||
|
data['user'] = user
|
||||||
|
return await handler(event, data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. FSM для авторизации
|
||||||
|
**Файл:** `utils/states.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
class RegistrationStates(StatesGroup):
|
||||||
|
waiting_email = State()
|
||||||
|
waiting_username = State()
|
||||||
|
waiting_password = State()
|
||||||
|
|
||||||
|
class LoginStates(StatesGroup):
|
||||||
|
waiting_email = State()
|
||||||
|
waiting_password = State()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Inline-клавиатуры [web:7][web:10]
|
||||||
|
**Файл:** `keyboards/inline.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from aiogram.types import InlineKeyboardButton
|
||||||
|
|
||||||
|
def get_main_menu():
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(text="📚 Все книги", callback_data="books_all")
|
||||||
|
builder.button(text="⭐ Избранное", callback_data="favorites")
|
||||||
|
builder.button(text="🔍 Поиск по жанру", callback_data="search_genre")
|
||||||
|
builder.button(text="👤 Мой профиль", callback_data="profile")
|
||||||
|
builder.adjust(2, 2) \# 2 кнопки в ряду
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Обработка картинок
|
||||||
|
Использовать `bot.send_photo()` с параметром `photo=cover_url` (если URL публичный).
|
||||||
|
|
||||||
|
Если картинка не загружается → показать текстовую заглушку: "🖼 [Обложка недоступна]"
|
||||||
|
|
||||||
|
## Требования к коду
|
||||||
|
|
||||||
|
1. **Асинхронность:** все функции БД и хендлеры должны быть `async/await` [web:3]
|
||||||
|
2. **Обработка ошибок:** try/except для всех запросов к БД
|
||||||
|
3. **Логирование:** использовать `logging` для отладки
|
||||||
|
4. **Типизация:** использовать type hints везде
|
||||||
|
5. **Документация:** docstrings для всех функций
|
||||||
|
6. **Безопасность:**
|
||||||
|
- Хешировать пароли через `bcrypt`
|
||||||
|
- Не логировать пароли
|
||||||
|
- SQL-инъекции предотвращаются через SQLAlchemy
|
||||||
|
7. **Простота:** избегать избыточных абстракций, код должен быть понятным
|
||||||
|
|
||||||
|
## Примеры запросов SQLAlchemy
|
||||||
|
|
||||||
|
**Получить пользователя:**
|
||||||
|
```
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from database.models import User
|
||||||
|
|
||||||
|
async def get_user_by_telegram_id(telegram_id: int):
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Получить книги с авторами (raw SQL через SQLAlchemy):**
|
||||||
|
```
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
async def get_books_with_details(offset: int = 0, limit: int = 10):
|
||||||
|
async with async_session() as session:
|
||||||
|
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()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Зависимости (requirements.txt)
|
||||||
|
```
|
||||||
|
|
||||||
|
aiogram>=3.4.0
|
||||||
|
asyncpg>=0.29.0
|
||||||
|
SQLAlchemy>=2.0.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
bcrypt>=4.1.0
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Дополнительные указания
|
||||||
|
|
||||||
|
1. **Начните с файла `main.py`** - инициализация бота, диспетчера, подключение роутеров [web:8]
|
||||||
|
2. **Используйте роутеры aiogram** для группировки хендлеров по функциональности [web:2]
|
||||||
|
3. **Тестируйте каждую функцию по отдельности** перед интеграцией
|
||||||
|
4. **Используйте callback_data формата:** `action:param` (например, `add_fav:123`)
|
||||||
|
5. **Обрабатывайте длинные сообщения:** если описание книги > 1024 символов, обрезать с "..."
|
||||||
|
6. **Escape специальных символов** в Markdown, если используете `parse_mode='Markdown'`
|
||||||
|
|
||||||
|
## Важно
|
||||||
|
|
||||||
|
- НЕ модифицировать существующую Django БД
|
||||||
|
- НЕ создавать новые таблицы
|
||||||
|
- Использовать только SELECT, INSERT, UPDATE, DELETE для существующих таблиц
|
||||||
|
- Следовать naming conventions Django (таблицы `app_model`, например `books_book`)
|
||||||
|
- При обновлении `telegram_id` проверять уникальность (один Telegram аккаунт = один пользователь)
|
||||||
|
|
||||||
|
## Пример main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
from config import BOT_TOKEN
|
||||||
|
from handlers import auth, books, favorites
|
||||||
|
from middlewares.auth import AuthMiddleware
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
bot = Bot(token=BOT_TOKEN)
|
||||||
|
dp = Dispatcher(storage=MemoryStorage())
|
||||||
|
|
||||||
|
# Регистрация middleware
|
||||||
|
dp.message.middleware(AuthMiddleware())
|
||||||
|
dp.callback_query.middleware(AuthMiddleware())
|
||||||
|
|
||||||
|
# Регистрация роутеров
|
||||||
|
dp.include_router(auth.router)
|
||||||
|
dp.include_router(books.router)
|
||||||
|
dp.include_router(favorites.router)
|
||||||
|
|
||||||
|
await bot.delete_webhook(drop_pending_updates=True)
|
||||||
|
await dp.start_polling(bot)
|
||||||
|
if __name__ == '__main__':
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Начни реализацию с базовой структуры проекта и авторизации!
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот MD-файл содержит полную спецификацию для Cursor. Он включает:[^3][^4][^5]
|
||||||
|
|
||||||
|
- Четкую структуру проекта с организацией файлов[^6]
|
||||||
|
- Схему существующей Django БД (на основе ваших моделей)
|
||||||
|
- Детальные функциональные требования для каждой фичи
|
||||||
|
- Примеры SQL-запросов и SQLAlchemy кода[^7]
|
||||||
|
- Инструкции по работе с inline-клавиатурами aiogram[^2][^1]
|
||||||
|
- Требования к асинхронности и middleware[^5]
|
||||||
|
- Готовый пример точки входа приложения
|
||||||
|
|
||||||
|
Просто скопируйте этот контент в `.cursorrules` или отдельный MD-файл в корне проекта, и Cursor сможет генерировать код согласно спецификации!
|
||||||
|
<span style="display:none">[^10][^8][^9]</span>
|
||||||
|
|
||||||
|
<div align="center">⁂</div>
|
||||||
|
|
||||||
|
[^1]: https://docs.aiogram.dev/en/latest/utils/keyboard.html
|
||||||
|
|
||||||
|
[^2]: https://stackoverflow.com/questions/67652875/how-to-make-an-inline-keyboard-in-aiogram
|
||||||
|
|
||||||
|
[^3]: https://habr.com/ru/articles/953902/
|
||||||
|
|
||||||
|
[^4]: https://github.com/MasterGroosha/aiogram-3-guide
|
||||||
|
|
||||||
|
[^5]: https://aiogram.dev
|
||||||
|
|
||||||
|
[^6]: https://www.youtube.com/watch?v=wRatZLMa4oE
|
||||||
|
|
||||||
|
[^7]: https://stackoverflow.com/questions/75900203/how-do-i-connect-my-telegram-bot-telebot-to-postgresql-url
|
||||||
|
|
||||||
|
[^8]: https://www.youtube.com/watch?v=LufjNEv0OuQ
|
||||||
|
|
||||||
|
[^9]: https://www.youtube.com/watch?v=mNrqcTl13Wg
|
||||||
|
|
||||||
|
[^10]: https://www.youtube.com/watch?v=z5JT-bPdusY
|
||||||
|
|
||||||
|
## Дополнительные указания
|
||||||
|
|
||||||
|
- Не пиши тесты
|
||||||
|
- Не пиши объясняющие файлы любого рода(.txt,.md и тд.)
|
||||||
13
config.py
Normal file
13
config.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
if not BOT_TOKEN:
|
||||||
|
raise ValueError("BOT_TOKEN не найден в .env файле")
|
||||||
|
if not DATABASE_URL:
|
||||||
|
raise ValueError("DATABASE_URL не найден в .env файле")
|
||||||
|
|
||||||
15
database/__init__.py
Normal file
15
database/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""Модуль для работы с базой данных"""
|
||||||
|
from database.connection import get_session, async_session_maker, engine
|
||||||
|
from database.models import User, Book, Author, Genre, Favorite
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'get_session',
|
||||||
|
'async_session_maker',
|
||||||
|
'engine',
|
||||||
|
'User',
|
||||||
|
'Book',
|
||||||
|
'Author',
|
||||||
|
'Genre',
|
||||||
|
'Favorite'
|
||||||
|
]
|
||||||
|
|
||||||
22
database/connection.py
Normal file
22
database/connection.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from config import DATABASE_URL
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=False, pool_pre_ping=True)
|
||||||
|
async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncSession:
|
||||||
|
"""Получить асинхронную сессию БД"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка в сессии БД: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
95
database/models.py
Normal file
95
database/models.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from sqlalchemy import BigInteger, String, Text, Numeric, Integer, DateTime, Boolean, ForeignKey, Table, Column
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Many-to-Many таблицы
|
||||||
|
books_book_authors = Table(
|
||||||
|
'books_book_authors',
|
||||||
|
Base.metadata,
|
||||||
|
Column('id', Integer, primary_key=True),
|
||||||
|
Column('book_id', Integer, ForeignKey('books_book.id')),
|
||||||
|
Column('author_id', Integer, ForeignKey('books_author.id'))
|
||||||
|
)
|
||||||
|
|
||||||
|
books_book_genres = Table(
|
||||||
|
'books_book_genres',
|
||||||
|
Base.metadata,
|
||||||
|
Column('id', Integer, primary_key=True),
|
||||||
|
Column('book_id', Integer, ForeignKey('books_book.id')),
|
||||||
|
Column('genre_id', Integer, ForeignKey('books_genre.id'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = 'auth_user'
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(150), unique=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(254), unique=True)
|
||||||
|
password: Mapped[str] = mapped_column(String(128))
|
||||||
|
telegram_id: Mapped[Optional[int]] = mapped_column(BigInteger, unique=True, nullable=True)
|
||||||
|
first_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||||
|
last_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
is_staff: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
date_joined: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
favorites: Mapped[List["Favorite"]] = relationship(back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
|
class Author(Base):
|
||||||
|
__tablename__ = 'books_author'
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
|
||||||
|
books: Mapped[List["Book"]] = relationship(secondary=books_book_authors, back_populates="authors")
|
||||||
|
|
||||||
|
|
||||||
|
class Genre(Base):
|
||||||
|
__tablename__ = 'books_genre'
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
|
||||||
|
books: Mapped[List["Book"]] = relationship(secondary=books_book_genres, back_populates="genres")
|
||||||
|
|
||||||
|
|
||||||
|
class Book(Base):
|
||||||
|
__tablename__ = 'books_book'
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(500))
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
cover_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
average_rating: Mapped[Optional[float]] = mapped_column(Numeric(3, 2), nullable=True)
|
||||||
|
rating_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
language: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
authors: Mapped[List[Author]] = relationship(secondary=books_book_authors, back_populates="books")
|
||||||
|
genres: Mapped[List[Genre]] = relationship(secondary=books_book_genres, back_populates="books")
|
||||||
|
favorites: Mapped[List["Favorite"]] = relationship(back_populates="book")
|
||||||
|
|
||||||
|
|
||||||
|
class Favorite(Base):
|
||||||
|
__tablename__ = 'books_favorite'
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey('auth_user.id'))
|
||||||
|
book_id: Mapped[int] = mapped_column(Integer, ForeignKey('books_book.id'))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="favorites")
|
||||||
|
book: Mapped[Book] = relationship(back_populates="favorites")
|
||||||
|
|
||||||
7
env.example
Normal file
7
env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Telegram Bot Token (получить у @BotFather)
|
||||||
|
BOT_TOKEN=your_bot_token_here
|
||||||
|
|
||||||
|
# PostgreSQL Database URL
|
||||||
|
# Формат: postgresql+asyncpg://username:password@host:port/database_name
|
||||||
|
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/dbname
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
19
keyboards/__init__.py
Normal file
19
keyboards/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Модуль клавиатур бота"""
|
||||||
|
from keyboards.inline import (
|
||||||
|
get_auth_menu,
|
||||||
|
get_main_menu,
|
||||||
|
get_book_keyboard,
|
||||||
|
get_pagination_keyboard,
|
||||||
|
get_genres_keyboard,
|
||||||
|
get_back_to_menu_keyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'get_auth_menu',
|
||||||
|
'get_main_menu',
|
||||||
|
'get_book_keyboard',
|
||||||
|
'get_pagination_keyboard',
|
||||||
|
'get_genres_keyboard',
|
||||||
|
'get_back_to_menu_keyboard'
|
||||||
|
]
|
||||||
|
|
||||||
89
keyboards/inline.py
Normal file
89
keyboards/inline.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_menu() -> InlineKeyboardMarkup:
|
||||||
|
"""Меню авторизации для неавторизованных пользователей"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(text="🔐 Войти", callback_data="login")
|
||||||
|
builder.button(text="📝 Зарегистрироваться", callback_data="register")
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_main_menu() -> InlineKeyboardMarkup:
|
||||||
|
"""Главное меню для авторизованных пользователей"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(text="📚 Все книги", callback_data="books_all")
|
||||||
|
builder.button(text="⭐ Избранное", callback_data="favorites")
|
||||||
|
builder.button(text="🔍 Поиск по жанру", callback_data="search_genre")
|
||||||
|
builder.button(text="👤 Мой профиль", callback_data="profile")
|
||||||
|
builder.adjust(2, 2)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_book_keyboard(book_id: int, is_favorite: bool = False, page: int = 0, total_pages: int = 1) -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура для отдельной книги с пагинацией"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
# Кнопка добавления/удаления из избранного
|
||||||
|
if is_favorite:
|
||||||
|
builder.button(text="❌ Удалить из избранного", callback_data=f"remove_fav:{book_id}")
|
||||||
|
else:
|
||||||
|
builder.button(text="❤️ В избранное", callback_data=f"add_fav:{book_id}")
|
||||||
|
|
||||||
|
# Кнопки пагинации
|
||||||
|
nav_buttons = []
|
||||||
|
if page > 0:
|
||||||
|
nav_buttons.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"books_page:{page-1}"))
|
||||||
|
|
||||||
|
nav_buttons.append(InlineKeyboardButton(text=f"{page+1}/{total_pages}", callback_data="page_info"))
|
||||||
|
|
||||||
|
if page < total_pages - 1:
|
||||||
|
nav_buttons.append(InlineKeyboardButton(text="➡️ Вперед", callback_data=f"books_page:{page+1}"))
|
||||||
|
|
||||||
|
builder.row(*nav_buttons)
|
||||||
|
builder.button(text="🏠 Главное меню", callback_data="main_menu")
|
||||||
|
|
||||||
|
builder.adjust(1, len(nav_buttons), 1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_pagination_keyboard(page: int, total_pages: int, prefix: str = "books_page") -> InlineKeyboardMarkup:
|
||||||
|
"""Общая клавиатура пагинации"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
nav_buttons = []
|
||||||
|
if page > 0:
|
||||||
|
nav_buttons.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"{prefix}:{page-1}"))
|
||||||
|
|
||||||
|
nav_buttons.append(InlineKeyboardButton(text=f"{page+1}/{total_pages}", callback_data="page_info"))
|
||||||
|
|
||||||
|
if page < total_pages - 1:
|
||||||
|
nav_buttons.append(InlineKeyboardButton(text="➡️ Вперед", callback_data=f"{prefix}:{page+1}"))
|
||||||
|
|
||||||
|
builder.row(*nav_buttons)
|
||||||
|
builder.button(text="🏠 Главное меню", callback_data="main_menu")
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_genres_keyboard(genres: List[Tuple[int, str]]) -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура с жанрами (по 2 в строке)"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
for genre_id, genre_name in genres:
|
||||||
|
builder.button(text=genre_name, callback_data=f"genre:{genre_id}")
|
||||||
|
|
||||||
|
builder.button(text="🏠 Главное меню", callback_data="main_menu")
|
||||||
|
builder.adjust(2)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_back_to_menu_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
"""Простая клавиатура с возвратом в меню"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(text="🏠 Главное меню", callback_data="main_menu")
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
63
main.py
Normal file
63
main.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
from config import BOT_TOKEN
|
||||||
|
from handlers import auth, books, favorites
|
||||||
|
from middlewares.auth import DatabaseMiddleware
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Главная функция запуска бота"""
|
||||||
|
logger.info("Запуск бота...")
|
||||||
|
|
||||||
|
# Инициализация бота и диспетчера
|
||||||
|
bot = Bot(
|
||||||
|
token=BOT_TOKEN,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||||
|
)
|
||||||
|
dp = Dispatcher(storage=MemoryStorage())
|
||||||
|
|
||||||
|
# Регистрация middleware
|
||||||
|
# DatabaseMiddleware предоставляет сессию БД для всех хендлеров
|
||||||
|
dp.message.middleware(DatabaseMiddleware())
|
||||||
|
dp.callback_query.middleware(DatabaseMiddleware())
|
||||||
|
|
||||||
|
# Регистрация роутеров
|
||||||
|
# Порядок важен: auth должен быть первым, так как обрабатывает /start
|
||||||
|
dp.include_router(auth.router)
|
||||||
|
dp.include_router(books.router)
|
||||||
|
dp.include_router(favorites.router)
|
||||||
|
|
||||||
|
# Удаление вебхука и очистка pending updates
|
||||||
|
await bot.delete_webhook(drop_pending_updates=True)
|
||||||
|
|
||||||
|
logger.info("Бот успешно запущен и готов к работе!")
|
||||||
|
|
||||||
|
# Запуск polling
|
||||||
|
try:
|
||||||
|
await dp.start_polling(bot)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при работе бота: {e}")
|
||||||
|
finally:
|
||||||
|
await bot.session.close()
|
||||||
|
logger.info("Бот остановлен")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Бот остановлен пользователем")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Критическая ошибка: {e}")
|
||||||
|
|
||||||
5
middlewares/__init__.py
Normal file
5
middlewares/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Модуль middleware для бота"""
|
||||||
|
from middlewares.auth import AuthMiddleware, DatabaseMiddleware
|
||||||
|
|
||||||
|
__all__ = ['AuthMiddleware', 'DatabaseMiddleware']
|
||||||
|
|
||||||
80
middlewares/auth.py
Normal file
80
middlewares/auth.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from typing import Callable, Dict, Any, Awaitable
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from database.connection import async_session_maker
|
||||||
|
from database.models import User
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMiddleware(BaseMiddleware):
|
||||||
|
"""
|
||||||
|
Middleware для проверки авторизации пользователя.
|
||||||
|
Пропускает команду /start и проверяет наличие telegram_id в БД для остальных запросов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[Message | CallbackQuery, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: Message | CallbackQuery,
|
||||||
|
data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
# Пропускаем команду /start
|
||||||
|
if isinstance(event, Message) and event.text and event.text.startswith('/start'):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
# Пропускаем callback'и регистрации и входа
|
||||||
|
if isinstance(event, CallbackQuery) and event.data:
|
||||||
|
if event.data in ['login', 'register', 'main_menu']:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
# Проверяем авторизацию
|
||||||
|
telegram_id = event.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:
|
||||||
|
if isinstance(event, CallbackQuery):
|
||||||
|
await event.answer("⛔ Сначала авторизуйтесь через /start", show_alert=True)
|
||||||
|
else:
|
||||||
|
await event.answer("⛔ Сначала авторизуйтесь через /start")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Добавляем пользователя в данные
|
||||||
|
data['user'] = user
|
||||||
|
data['session'] = session
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка в AuthMiddleware: {e}")
|
||||||
|
if isinstance(event, CallbackQuery):
|
||||||
|
await event.answer("❌ Ошибка проверки авторизации", show_alert=True)
|
||||||
|
else:
|
||||||
|
await event.answer("❌ Ошибка проверки авторизации")
|
||||||
|
return
|
||||||
|
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseMiddleware(BaseMiddleware):
|
||||||
|
"""
|
||||||
|
Middleware для предоставления сессии БД в обработчики.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[Message | CallbackQuery, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: Message | CallbackQuery,
|
||||||
|
data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
data['session'] = session
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
aiogram>=3.4.0
|
||||||
|
asyncpg>=0.29.0
|
||||||
|
SQLAlchemy>=2.0.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
bcrypt>=4.1.0
|
||||||
|
|
||||||
5
utils/__init__.py
Normal file
5
utils/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Утилиты и вспомогательные классы"""
|
||||||
|
from utils.states import RegistrationStates, LoginStates, BooksPagination, GenreFilter
|
||||||
|
|
||||||
|
__all__ = ['RegistrationStates', 'LoginStates', 'BooksPagination', 'GenreFilter']
|
||||||
|
|
||||||
25
utils/states.py
Normal file
25
utils/states.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationStates(StatesGroup):
|
||||||
|
"""Состояния для регистрации нового пользователя"""
|
||||||
|
waiting_email = State()
|
||||||
|
waiting_username = State()
|
||||||
|
waiting_password = State()
|
||||||
|
|
||||||
|
|
||||||
|
class LoginStates(StatesGroup):
|
||||||
|
"""Состояния для входа существующего пользователя"""
|
||||||
|
waiting_email = State()
|
||||||
|
waiting_password = State()
|
||||||
|
|
||||||
|
|
||||||
|
class BooksPagination(StatesGroup):
|
||||||
|
"""Состояния для пагинации книг"""
|
||||||
|
browsing = State()
|
||||||
|
|
||||||
|
|
||||||
|
class GenreFilter(StatesGroup):
|
||||||
|
"""Состояния для фильтрации по жанрам"""
|
||||||
|
selecting = State()
|
||||||
|
|
||||||
Reference in New Issue
Block a user