This commit is contained in:
2025-09-14 22:09:39 +05:00
parent 8ad2bfbe59
commit 3c418a97b4
30 changed files with 3404 additions and 0 deletions

11
database/.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Переменные окружения для подключения к PostgreSQL
# Параметры подключения к базе данных
DB_USER=postgres
DB_PASSWORD=your_password_here
DB_HOST=localhost
DB_PORT=5432
DB_NAME=telegram_bot
# Для продакшена можно также использовать полную строку подключения:
# DATABASE_URL=postgresql://username:password@localhost:5432/database_name

102
database/README.md Normal file
View File

@@ -0,0 +1,102 @@
# База данных PostgreSQL для Telegram бота
Этот модуль содержит полную настройку для работы с PostgreSQL через SQLAlchemy.
## Структура файлов
- `database.py` - Основные классы для подключения к базе данных
- `models.py` - Модели SQLAlchemy (таблицы)
- `repositories.py` - Репозитории для работы с данными
- `services.py` - Высокоуровневые сервисы
- `example.py` - Примеры использования
- `requirements.txt` - Зависимости
- `.env.example` - Пример файла с переменными окружения
## Быстрый старт
### 1. Установка зависимостей
```bash
pip install -r requirements.txt
```
### 2. Настройка базы данных
Создайте файл `.env` на основе `.env.example`:
```bash
DB_USER=postgres
DB_PASSWORD=your_password
DB_HOST=localhost
DB_PORT=5432
DB_NAME=telegram_bot
```
### 3. Основное использование
```python
from database import db_service, setup_database
# Настройка базы данных (создание таблиц)
setup_database()
# Регистрация пользователя
user, created = db_service.register_user(
telegram_id=123456789,
username="example_user",
first_name="Иван"
)
# Сохранение сообщения
message = db_service.save_message(
telegram_message_id=1001,
telegram_user_id=123456789,
text="Привет!"
)
# Работа с настройками
db_service.set_setting("bot_name", "Мой Бот")
bot_name = db_service.get_setting("bot_name")
```
## Основные возможности
### Модели данных
- **User** - Пользователи телеграм бота
- **Message** - Сообщения от пользователей
- **BotSettings** - Настройки бота
### Репозитории
- **UserRepository** - CRUD операции с пользователями
- **MessageRepository** - CRUD операции с сообщениями
- **SettingsRepository** - CRUD операции с настройками
### Сервисы
- **DatabaseService** - Высокоуровневый API для работы с данными
## Примеры использования
Запустите `example.py` для демонстрации основных возможностей:
```bash
python example.py
```
## Настройки
Все настройки подключения к базе данных можно задать через переменные окружения:
- `DB_USER` - имя пользователя PostgreSQL
- `DB_PASSWORD` - пароль
- `DB_HOST` - хост (по умолчанию localhost)
- `DB_PORT` - порт (по умолчанию 5432)
- `DB_NAME` - имя базы данных
## Требования
- Python 3.8+
- PostgreSQL 10+
- SQLAlchemy 2.0+

37
database/__init__.py Normal file
View File

@@ -0,0 +1,37 @@
"""
Инициализация пакета database
"""
from .database import Database, DatabaseConfig, db, get_db_session, init_database, test_db_connection
from .models import User, Message, BotSettings
from .repositories import UserRepository, MessageRepository, SettingsRepository
from .services import DatabaseService, db_service, setup_database
__all__ = [
# Основные классы
'Database',
'DatabaseConfig',
# Глобальные объекты
'db',
'db_service',
# Функции
'get_db_session',
'init_database',
'test_db_connection',
'setup_database',
# Модели
'User',
'Message',
'BotSettings',
# Репозитории
'UserRepository',
'MessageRepository',
'SettingsRepository',
# Сервисы
'DatabaseService',
]

180
database/database.py Normal file
View File

@@ -0,0 +1,180 @@
"""
Модуль для подключения к базе данных PostgreSQL через SQLAlchemy
"""
from sqlalchemy import create_engine, MetaData
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from contextlib import contextmanager
import os
from typing import Generator
import logging
# Настройка логирования
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Базовый класс для всех моделей
Base = declarative_base()
class DatabaseConfig:
"""Конфигурация базы данных"""
def __init__(self):
# Параметры подключения к PostgreSQL
self.DB_USER = os.getenv('DB_USER', 'postgres')
self.DB_PASSWORD = os.getenv('DB_PASSWORD', 'password')
self.DB_HOST = os.getenv('DB_HOST', 'localhost')
self.DB_PORT = os.getenv('DB_PORT', '5432')
self.DB_NAME = os.getenv('DB_NAME', 'telegram_bot')
# Строка подключения
self.DATABASE_URL = f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
# Настройки SQLAlchemy
self.SQLALCHEMY_ENGINE_OPTIONS = {
'pool_pre_ping': True, # Проверка соединения перед использованием
'pool_recycle': 300, # Переподключение каждые 5 минут
'pool_size': 10, # Размер пула соединений
'max_overflow': 20, # Максимальное количество дополнительных соединений
'echo': False, # Логирование SQL запросов (для отладки)
}
class Database:
"""Класс для управления подключением к базе данных"""
def __init__(self, config: DatabaseConfig = None):
self.config = config or DatabaseConfig()
self.engine = None
self.SessionLocal = None
self._initialize()
def _initialize(self):
"""Инициализация подключения к базе данных"""
try:
# Создание движка SQLAlchemy
self.engine = create_engine(
self.config.DATABASE_URL,
**self.config.SQLALCHEMY_ENGINE_OPTIONS
)
# Создание фабрики сессий
self.SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=self.engine
)
logger.info("Подключение к базе данных успешно инициализировано")
except Exception as e:
logger.error(f"Ошибка при инициализации базы данных: {e}")
raise
def create_tables(self):
"""Создание всех таблиц в базе данных"""
try:
Base.metadata.create_all(bind=self.engine)
logger.info("Таблицы успешно созданы")
except Exception as e:
logger.error(f"Ошибка при создании таблиц: {e}")
raise
def drop_tables(self):
"""Удаление всех таблиц из базы данных"""
try:
Base.metadata.drop_all(bind=self.engine)
logger.info("Таблицы успешно удалены")
except Exception as e:
logger.error(f"Ошибка при удалении таблиц: {e}")
raise
@contextmanager
def get_session(self) -> Generator:
"""Контекстный менеджер для получения сессии базы данных"""
session = self.SessionLocal()
try:
yield session
session.commit()
except Exception as e:
session.rollback()
logger.error(f"Ошибка в сессии базы данных: {e}")
raise
finally:
session.close()
def get_session_sync(self):
"""Получение синхронной сессии (не забудьте закрыть!)"""
return self.SessionLocal()
def test_connection(self) -> bool:
"""Проверка подключения к базе данных"""
try:
with self.get_session() as session:
session.execute("SELECT 1")
logger.info("Подключение к базе данных работает корректно")
return True
except Exception as e:
logger.error(f"Ошибка подключения к базе данных: {e}")
return False
def close(self):
"""Закрытие подключения к базе данных"""
if self.engine:
self.engine.dispose()
logger.info("Подключение к базе данных закрыто")
# Глобальный экземпляр базы данных
db = Database()
# Удобные функции для использования в приложении
def get_db_session():
"""Получение сессии базы данных для использования в приложении"""
return db.get_session()
def init_database():
"""Инициализация базы данных (создание таблиц)"""
db.create_tables()
def test_db_connection():
"""Тестирование подключения к базе данных"""
return db.test_connection()
def load_image_from_file(file_path: str, alt_text: str = None):
"""
Быстрая загрузка изображения из файла в базу данных
Args:
file_path: Путь к файлу изображения
alt_text: Альтернативный текст
Returns:
Image: Объект изображения или None при ошибке
"""
import os
import mimetypes
from database.repositories import ImageRepository
try:
if not os.path.isfile(file_path):
return None
filename = os.path.basename(file_path)
mime_type, _ = mimetypes.guess_type(filename)
if not mime_type:
mime_type = 'image/jpeg'
with open(file_path, 'rb') as f:
image_data = f.read()
with get_db_session() as session:
image = ImageRepository.create_image(
session, filename, mime_type, image_data, alt_text
)
session.commit()
return image
except Exception as e:
logger.error(f"Ошибка загрузки изображения {file_path}: {e}")
return None

145
database/example.py Normal file
View File

@@ -0,0 +1,145 @@
"""
Пример использования базы данных
"""
import os
import sys
from pathlib import Path
# Добавляем путь к проекту в PYTHONPATH
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from database.database import db, test_db_connection
from database.services import db_service, setup_database
import logging
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def main():
"""Основная функция для демонстрации работы с базой данных"""
print("🚀 Пример работы с PostgreSQL через SQLAlchemy")
print("=" * 50)
# 1. Тестирование подключения
print("1. Тестирование подключения к базе данных...")
if test_db_connection():
print("✅ Подключение успешно!")
else:
print("❌ Ошибка подключения!")
return
# 2. Настройка базы данных (создание таблиц)
print("\n2. Настройка базы данных...")
if setup_database():
print("✅ База данных настроена!")
else:
print("❌ Ошибка настройки базы данных!")
return
# 3. Работа с пользователями
print("\n3. Работа с пользователями...")
# Регистрация пользователя
user, created = db_service.register_user(
telegram_id=123456789,
username="test_user",
first_name="Иван",
last_name="Иванов"
)
print(f"Пользователь: {user.first_name} {user.last_name} (@{user.username})")
print(f"Создан новый: {'Да' if created else 'Нет'}")
# Повторная регистрация (должен вернуть существующего)
user2, created2 = db_service.register_user(
telegram_id=123456789,
username="test_user_updated"
)
print(f"Повторная регистрация - создан новый: {'Да' if created2 else 'Нет'}")
# 4. Работа с сообщениями
print("\n4. Работа с сообщениями...")
# Сохранение сообщения
message = db_service.save_message(
telegram_message_id=1001,
telegram_user_id=123456789,
text="Привет! Это тестовое сообщение",
message_type="text"
)
print(f"Сообщение сохранено: ID {message.id if message else 'Ошибка'}")
# Получение сообщений пользователя
messages = db_service.get_user_messages(123456789, limit=10)
print(f"Количество сообщений пользователя: {len(messages)}")
# 5. Работа с настройками
print("\n5. Работа с настройками...")
# Установка настройки
db_service.set_setting("welcome_message", "Добро пожаловать в бот!", "Приветственное сообщение")
# Получение настройки
welcome_msg = db_service.get_setting("welcome_message", "Привет!")
print(f"Приветственное сообщение: {welcome_msg}")
# 6. Статистика
print("\n6. Статистика...")
user_count = db_service.get_user_count()
print(f"Всего пользователей: {user_count}")
print("\n✅ Демонстрация завершена успешно!")
def example_usage_in_telegram_bot():
"""Пример использования в телеграм боте"""
print("\n" + "=" * 50)
print("📱 Пример использования в телеграм боте:")
print("=" * 50)
# Имитация обработки сообщения от пользователя
telegram_user_id = 987654321
message_text = "Какая сегодня погода?"
# 1. Регистрируем/получаем пользователя
user, created = db_service.register_user(
telegram_id=telegram_user_id,
username="weather_user",
first_name="Мария"
)
if created:
print(f"🆕 Новый пользователь: {user.first_name}")
# Отправляем приветственное сообщение
welcome_msg = db_service.get_setting("welcome_message", "Привет!")
print(f"Отправляем: {welcome_msg}")
# 2. Сохраняем сообщение
message = db_service.save_message(
telegram_message_id=2001,
telegram_user_id=telegram_user_id,
text=message_text
)
print(f"💬 Сохранено сообщение: '{message_text[:30]}...'")
# 3. Обновляем активность пользователя
db_service.update_user_info(telegram_user_id, is_active=True)
print("✅ Обработка сообщения завершена")
if __name__ == "__main__":
try:
main()
example_usage_in_telegram_bot()
except Exception as e:
logger.error(f"Ошибка в примере: {e}")
print(f"❌ Ошибка: {e}")
finally:
# Закрываем подключение
db.close()
print("\n🔒 Подключение к базе данных закрыто")

271
database/load_images.py Normal file
View File

@@ -0,0 +1,271 @@
"""
Скрипт для загрузки изображений из папки images/ в базу данных
"""
import os
import mimetypes
from pathlib import Path
from database.database import db, get_db_session
from database.services import ImageService
from database.repositories import ImageRepository
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_image_content_type(filename: str) -> str:
"""Определение MIME типа изображения по расширению файла"""
mime_type, _ = mimetypes.guess_type(filename)
# Если mimetypes не смог определить, делаем по расширению
if not mime_type:
ext = filename.lower().split('.')[-1]
mime_types = {
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'bmp': 'image/bmp',
'webp': 'image/webp'
}
mime_type = mime_types.get(ext, 'image/jpeg')
return mime_type
def load_image_to_db(image_path: str, alt_text: str = None) -> bool:
"""
Загрузка одного изображения в базу данных
Args:
image_path: Путь к файлу изображения
alt_text: Альтернативный текст для изображения
Returns:
bool: True если загрузка успешна, False если ошибка
"""
try:
# Проверяем, существует ли файл
if not os.path.isfile(image_path):
logger.error(f"Файл не найден: {image_path}")
return False
# Получаем информацию о файле
filename = os.path.basename(image_path)
content_type = get_image_content_type(filename)
# Читаем бинарные данные
with open(image_path, 'rb') as f:
image_data = f.read()
# Проверяем размер файла (ограничение PostgreSQL ~1GB, но лучше меньше)
file_size = len(image_data)
if file_size > 10 * 1024 * 1024: # 10MB лимит
logger.warning(f"Файл слишком большой ({file_size} байт): {filename}")
return False
# Сохраняем в базу данных
with get_db_session() as session:
# Проверяем, не существует ли уже такой файл
existing_image = ImageRepository.get_image_by_filename(session, filename)
if existing_image:
logger.info(f"Изображение уже существует в БД: {filename}")
return True
# Создаем новую запись
image = ImageRepository.create_image(
session=session,
filename=filename,
content_type=content_type,
image_data=image_data,
alt_text=alt_text or f"Изображение {filename}"
)
session.commit()
logger.info(f"Загружено изображение: {filename} (ID: {image.id}, размер: {file_size} байт)")
return True
except Exception as e:
logger.error(f"Ошибка при загрузке изображения {image_path}: {e}")
return False
def load_images_from_directory(images_dir: str = "images") -> dict:
"""
Загрузка всех изображений из указанной директории
Args:
images_dir: Путь к директории с изображениями
Returns:
dict: Статистика загрузки
"""
if not os.path.isdir(images_dir):
logger.error(f"Директория не найдена: {images_dir}")
return {"success": 0, "failed": 0, "skipped": 0}
# Поддерживаемые форматы изображений
supported_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'}
stats = {"success": 0, "failed": 0, "skipped": 0}
logger.info(f"Начинаем загрузку изображений из директории: {images_dir}")
# Проходим по всем файлам в директории
for filename in os.listdir(images_dir):
file_path = os.path.join(images_dir, filename)
# Пропускаем директории
if os.path.isdir(file_path):
continue
# Проверяем расширение файла
file_ext = Path(filename).suffix.lower()
if file_ext not in supported_extensions:
logger.info(f"Пропускаем файл неподдерживаемого формата: {filename}")
stats["skipped"] += 1
continue
# Загружаем изображение
if load_image_to_db(file_path):
stats["success"] += 1
else:
stats["failed"] += 1
logger.info(f"Загрузка завершена. Успешно: {stats['success']}, "
f"Ошибок: {stats['failed']}, Пропущено: {stats['skipped']}")
return stats
def list_images_in_db():
"""Показать все изображения в базе данных"""
try:
with get_db_session() as session:
images = ImageRepository.get_all_images(session)
if not images:
logger.info("В базе данных нет изображений")
return
logger.info(f"Изображения в базе данных ({len(images)} шт.):")
for image in images:
size_kb = image.file_size / 1024
logger.info(f" ID: {image.id}, Файл: {image.filename}, "
f"Тип: {image.content_type}, Размер: {size_kb:.1f} KB")
except Exception as e:
logger.error(f"Ошибка при получении списка изображений: {e}")
def delete_image_from_db(filename: str = None, image_id: int = None) -> bool:
"""
Удаление изображения из базы данных
Args:
filename: Имя файла для удаления
image_id: ID изображения для удаления
Returns:
bool: True если удаление успешно
"""
try:
with get_db_session() as session:
if filename:
image = ImageRepository.get_image_by_filename(session, filename)
elif image_id:
image = ImageRepository.get_image_by_id(session, image_id)
else:
logger.error("Необходимо указать filename или image_id")
return False
if not image:
logger.error("Изображение не найдено")
return False
if ImageRepository.delete_image(session, image.id):
session.commit()
logger.info(f"Изображение удалено: {image.filename}")
return True
else:
logger.error("Ошибка при удалении изображения")
return False
except Exception as e:
logger.error(f"Ошибка при удалении изображения: {e}")
return False
if __name__ == "__main__":
"""Главная функция для запуска скрипта"""
print("🖼️ Скрипт загрузки изображений в базу данных")
print("=" * 50)
# Проверяем подключение к БД
try:
from database.database import test_db_connection
if not test_db_connection():
print("❌ Ошибка подключения к базе данных!")
exit(1)
print("✅ Подключение к базе данных успешно")
except Exception as e:
print(f"❌ Ошибка: {e}")
exit(1)
# Меню
while True:
print("\nВыберите действие:")
print("1. Загрузить все изображения из папки images/")
print("2. Загрузить конкретное изображение")
print("3. Показать все изображения в БД")
print("4. Удалить изображение из БД")
print("5. Выход")
choice = input("\nВведите номер (1-5): ").strip()
if choice == "1":
stats = load_images_from_directory("images")
print(f"\n📊 Результат: загружено {stats['success']}, "
f"ошибок {stats['failed']}, пропущено {stats['skipped']}")
elif choice == "2":
image_path = input("Введите путь к изображению: ").strip()
alt_text = input("Введите описание (необязательно): ").strip() or None
if load_image_to_db(image_path, alt_text):
print("✅ Изображение загружено!")
else:
print("❌ Ошибка загрузки")
elif choice == "3":
list_images_in_db()
elif choice == "4":
print("Удаление по:")
print("1. Имени файла")
print("2. ID")
sub_choice = input("Выберите (1-2): ").strip()
if sub_choice == "1":
filename = input("Введите имя файла: ").strip()
if delete_image_from_db(filename=filename):
print("✅ Изображение удалено!")
else:
print("❌ Ошибка удаления")
elif sub_choice == "2":
try:
image_id = int(input("Введите ID: ").strip())
if delete_image_from_db(image_id=image_id):
print("✅ Изображение удалено!")
else:
print("❌ Ошибка удаления")
except ValueError:
print("❌ Неверный ID")
elif choice == "5":
print("👋 До свидания!")
break
else:
print("❌ Неверный выбор")

208
database/models.py Normal file
View File

@@ -0,0 +1,208 @@
"""
Базовые модели для работы с базой данных
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, BigInteger, ForeignKey, LargeBinary
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database.database import Base
class User(Base):
"""Модель пользователя телеграм бота"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
telegram_id = Column(BigInteger, unique=True, index=True, nullable=False)
username = Column(String(255), nullable=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
language_code = Column(String(10), default='ru')
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Связь с результатами тестов
test_results = relationship("TestResult", back_populates="user")
def __repr__(self):
return f"<User(id={self.id}, telegram_id={self.telegram_id}, username={self.username})>"
class Message(Base):
"""Модель сообщений"""
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
telegram_message_id = Column(BigInteger, nullable=False)
user_id = Column(Integer, nullable=False) # Внешний ключ на users.id
text = Column(Text, nullable=True)
message_type = Column(String(50), default='text') # text, photo, document, etc.
created_at = Column(DateTime(timezone=True), server_default=func.now())
def __repr__(self):
return f"<Message(id={self.id}, user_id={self.user_id}, type={self.message_type})>"
class BotSettings(Base):
"""Модель настроек бота"""
__tablename__ = "bot_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(255), unique=True, nullable=False)
value = Column(Text, nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
def __repr__(self):
return f"<BotSettings(key={self.key}, value={self.value})>"
# ===== МОДЕЛИ ДЛЯ СИСТЕМЫ ТЕСТИРОВАНИЯ =====
class TestCategory(Base):
"""Категории тестов"""
__tablename__ = "test_categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), unique=True, nullable=False) # "Тест Климова", "Профориентация" и т.д.
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Связь с тестами
tests = relationship("Test", back_populates="category")
def __repr__(self):
return f"<TestCategory(id={self.id}, name={self.name})>"
class Test(Base):
"""Конкретные тесты"""
__tablename__ = "tests"
id = Column(Integer, primary_key=True, index=True)
category_id = Column(Integer, ForeignKey("test_categories.id"), nullable=False)
title = Column(String(255), nullable=False) # "📘 Тест Климова"
description = Column(Text, nullable=True)
instructions = Column(Text, nullable=True) # Инструкции перед тестом
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Связи
category = relationship("TestCategory", back_populates="tests")
questions = relationship("Question", back_populates="test", cascade="all, delete-orphan")
user_results = relationship("TestResult", back_populates="test")
def __repr__(self):
return f"<Test(id={self.id}, title={self.title})>"
class Image(Base):
"""Хранение изображений в базе данных"""
__tablename__ = "images"
id = Column(Integer, primary_key=True, index=True)
filename = Column(String(255), nullable=False) # Оригинальное имя файла
content_type = Column(String(100), nullable=False) # image/jpeg, image/png и т.д.
file_size = Column(Integer, nullable=False) # Размер в байтах
image_data = Column(LargeBinary, nullable=False) # Бинарные данные изображения
alt_text = Column(String(255), nullable=True) # Альтернативный текст для доступности
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Связь с вопросами
questions = relationship("Question", back_populates="image")
def __repr__(self):
return f"<Image(id={self.id}, filename={self.filename}, size={self.file_size})>"
class Question(Base):
"""Вопросы тестов"""
__tablename__ = "questions"
id = Column(Integer, primary_key=True, index=True)
test_id = Column(Integer, ForeignKey("tests.id"), nullable=False)
order_index = Column(Integer, nullable=False) # Порядок вопроса в тесте
question_text = Column(Text, nullable=True) # Текст вопроса (может быть пустым если только картинка)
image_id = Column(Integer, ForeignKey("images.id"), nullable=True) # Привязка к изображению
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Связи
test = relationship("Test", back_populates="questions")
image = relationship("Image", back_populates="questions")
answer_options = relationship("AnswerOption", back_populates="question", cascade="all, delete-orphan")
user_answers = relationship("UserAnswer", back_populates="question")
def __repr__(self):
return f"<Question(id={self.id}, test_id={self.test_id}, order={self.order_index})>"
class AnswerOption(Base):
"""Варианты ответов на вопросы"""
__tablename__ = "answer_options"
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("questions.id"), nullable=False)
option_text = Column(Text, nullable=False) # Текст варианта ответа
category = Column(String(255), nullable=True) # Категория для подсчета результатов (например, "Человек-природа")
order_index = Column(Integer, nullable=False) # Порядок варианта (1, 2, 3...)
is_correct = Column(Boolean, default=False) # Для тестов с правильными ответами
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Связи
question = relationship("Question", back_populates="answer_options")
user_answers = relationship("UserAnswer", back_populates="selected_option")
def __repr__(self):
return f"<AnswerOption(id={self.id}, question_id={self.question_id}, text='{self.option_text[:50]}')>"
class TestResult(Base):
"""Результаты прохождения тестов пользователями"""
__tablename__ = "test_results"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
test_id = Column(Integer, ForeignKey("tests.id"), nullable=False)
started_at = Column(DateTime(timezone=True), server_default=func.now())
completed_at = Column(DateTime(timezone=True), nullable=True)
is_completed = Column(Boolean, default=False)
result_data = Column(Text, nullable=True) # JSON с результатами по категориям
# Связи
user = relationship("User", back_populates="test_results")
test = relationship("Test", back_populates="user_results")
answers = relationship("UserAnswer", back_populates="test_result", cascade="all, delete-orphan")
def __repr__(self):
return f"<TestResult(id={self.id}, user_id={self.user_id}, test_id={self.test_id}, completed={self.is_completed})>"
class UserAnswer(Base):
"""Ответы пользователей на конкретные вопросы"""
__tablename__ = "user_answers"
id = Column(Integer, primary_key=True, index=True)
test_result_id = Column(Integer, ForeignKey("test_results.id"), nullable=False)
question_id = Column(Integer, ForeignKey("questions.id"), nullable=False)
selected_option_id = Column(Integer, ForeignKey("answer_options.id"), nullable=False)
answered_at = Column(DateTime(timezone=True), server_default=func.now())
# Связи
test_result = relationship("TestResult", back_populates="answers")
question = relationship("Question", back_populates="user_answers")
selected_option = relationship("AnswerOption", back_populates="user_answers")
def __repr__(self):
return f"<UserAnswer(id={self.id}, question_id={self.question_id}, option_id={self.selected_option_id})>"

185
database/repositories.py Normal file
View File

@@ -0,0 +1,185 @@
"""
Репозитории для работы с моделями базы данных системы тестирования
"""
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from database.models import (
User, Message, BotSettings, Test, TestCategory, Question,
AnswerOption, TestResult, UserAnswer, Image
)
import logging
logger = logging.getLogger(__name__)
class UserRepository:
"""Репозиторий для работы с пользователями"""
@staticmethod
def create_user(session: Session, telegram_id: int, username: str = None,
first_name: str = None, last_name: str = None) -> User:
"""Создание нового пользователя"""
user = User(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name
)
session.add(user)
session.flush() # Получаем ID без коммита
return user
@staticmethod
def get_user_by_telegram_id(session: Session, telegram_id: int) -> Optional[User]:
"""Получение пользователя по Telegram ID"""
return session.query(User).filter(User.telegram_id == telegram_id).first()
@staticmethod
def get_user_by_id(session: Session, user_id: int) -> Optional[User]:
"""Получение пользователя по ID"""
return session.query(User).filter(User.id == user_id).first()
@staticmethod
def get_all_users(session: Session, active_only: bool = True) -> List[User]:
"""Получение всех пользователей"""
query = session.query(User)
if active_only:
query = query.filter(User.is_active == True)
return query.all()
@staticmethod
def update_user(session: Session, telegram_id: int, **kwargs) -> Optional[User]:
"""Обновление данных пользователя"""
user = UserRepository.get_user_by_telegram_id(session, telegram_id)
if user:
for key, value in kwargs.items():
if hasattr(user, key):
setattr(user, key, value)
session.flush()
return user
@staticmethod
def get_or_create_user(session: Session, telegram_id: int, **kwargs) -> tuple[User, bool]:
"""Получение или создание пользователя. Возвращает (user, created)"""
user = UserRepository.get_user_by_telegram_id(session, telegram_id)
if user:
return user, False
else:
user = UserRepository.create_user(session, telegram_id, **kwargs)
return user, True
class MessageRepository:
"""Репозиторий для работы с сообщениями"""
@staticmethod
def create_message(session: Session, telegram_message_id: int, user_id: int,
text: str = None, message_type: str = 'text') -> Message:
"""Создание нового сообщения"""
message = Message(
telegram_message_id=telegram_message_id,
user_id=user_id,
text=text,
message_type=message_type
)
session.add(message)
session.flush()
return message
@staticmethod
def get_messages_by_user(session: Session, user_id: int, limit: int = 100) -> List[Message]:
"""Получение сообщений пользователя"""
return (session.query(Message)
.filter(Message.user_id == user_id)
.order_by(Message.created_at.desc())
.limit(limit)
.all())
@staticmethod
def get_recent_messages(session: Session, limit: int = 100) -> List[Message]:
"""Получение последних сообщений"""
return (session.query(Message)
.order_by(Message.created_at.desc())
.limit(limit)
.all())
class SettingsRepository:
"""Репозиторий для работы с настройками бота"""
@staticmethod
def get_setting(session: Session, key: str) -> Optional[str]:
"""Получение значения настройки"""
setting = session.query(BotSettings).filter(BotSettings.key == key).first()
return setting.value if setting else None
@staticmethod
def set_setting(session: Session, key: str, value: str, description: str = None) -> BotSettings:
"""Установка значения настройки"""
setting = session.query(BotSettings).filter(BotSettings.key == key).first()
if setting:
setting.value = value
if description:
setting.description = description
else:
setting = BotSettings(key=key, value=value, description=description)
session.add(setting)
session.flush()
return setting
@staticmethod
def get_all_settings(session: Session) -> List[BotSettings]:
"""Получение всех настроек"""
return session.query(BotSettings).all()
@staticmethod
def delete_setting(session: Session, key: str) -> bool:
"""Удаление настройки"""
setting = session.query(BotSettings).filter(BotSettings.key == key).first()
if setting:
session.delete(setting)
session.flush()
return True
return False
class ImageRepository:
"""Репозиторий для работы с изображениями"""
@staticmethod
def create_image(session: Session, filename: str, content_type: str,
image_data: bytes, alt_text: str = None) -> Image:
"""Создание записи изображения"""
image = Image(
filename=filename,
content_type=content_type,
file_size=len(image_data),
image_data=image_data,
alt_text=alt_text
)
session.add(image)
session.flush()
return image
@staticmethod
def get_image_by_id(session: Session, image_id: int) -> Optional[Image]:
"""Получение изображения по ID"""
return session.query(Image).filter(Image.id == image_id).first()
@staticmethod
def get_image_by_filename(session: Session, filename: str) -> Optional[Image]:
"""Получение изображения по имени файла"""
return session.query(Image).filter(Image.filename == filename).first()
@staticmethod
def get_all_images(session: Session) -> List[Image]:
"""Получение всех изображений"""
return session.query(Image).all()
@staticmethod
def delete_image(session: Session, image_id: int) -> bool:
"""Удаление изображения"""
image = session.query(Image).filter(Image.id == image_id).first()
if image:
session.delete(image)
return True
return False

View File

@@ -0,0 +1,6 @@
# Необходимые зависимости для работы с PostgreSQL и SQLAlchemy
sqlalchemy>=2.0.0
psycopg2-binary>=2.9.0 # PostgreSQL адаптер для Python
python-dotenv>=1.0.0 # Для работы с переменными окружения
alembic>=1.12.0 # Для миграций базы данных (опционально)

358
database/services.py Normal file
View File

@@ -0,0 +1,358 @@
"""
Сервисы для работы с системой тестирования
Основная бизнес-логика для получения вопросов, сохранения ответов и работы с изображениями
"""
import json
import io
from typing import List, Optional, Dict, Any, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_
from database.models import (
User, Test, TestCategory, Question, AnswerOption,
TestResult, UserAnswer, Image
)
import logging
logger = logging.getLogger(__name__)
class ImageService:
"""Сервис для работы с изображениями в базе данных"""
@staticmethod
def save_image_to_db(
db: Session,
filename: str,
content_type: str,
image_data: bytes,
alt_text: str = None
) -> Image:
"""
Сохранение изображения в базу данных
Args:
db: Сессия базы данных
filename: Имя файла
content_type: MIME тип (image/jpeg, image/png и т.д.)
image_data: Бинарные данные изображения
alt_text: Альтернативный текст
Returns:
Image: Созданная запись изображения
"""
image = Image(
filename=filename,
content_type=content_type,
file_size=len(image_data),
image_data=image_data,
alt_text=alt_text
)
db.add(image)
db.commit()
db.refresh(image)
return image
@staticmethod
def get_image_data(db: Session, image_id: int) -> Optional[Tuple[bytes, str, str]]:
"""
Получение данных изображения из БД
Args:
db: Сессия базы данных
image_id: ID изображения
Returns:
Tuple[bytes, str, str]: (image_data, content_type, filename) или None
"""
image = db.query(Image).filter(Image.id == image_id).first()
if image:
return image.image_data, image.content_type, image.filename
return None
@staticmethod
def get_image_by_filename(db: Session, filename: str) -> Optional[Image]:
"""Получение изображения по имени файла"""
return db.query(Image).filter(Image.filename == filename).first()
class TestService:
"""Сервис для работы с тестами"""
@staticmethod
def get_available_tests(db: Session) -> List[Test]:
"""Получение всех активных тестов"""
return db.query(Test).filter(Test.is_active == True).all()
@staticmethod
def get_test_by_title(db: Session, title: str) -> Optional[Test]:
"""Получение теста по названию (например, '📘 Тест Климова')"""
return db.query(Test).filter(
and_(Test.title == title, Test.is_active == True)
).first()
@staticmethod
def get_test_questions(db: Session, test_id: int) -> List[Question]:
"""
Получение всех вопросов теста в правильном порядке
Args:
db: Сессия базы данных
test_id: ID теста
Returns:
List[Question]: Список вопросов с загруженными связями
"""
return db.query(Question).filter(
and_(Question.test_id == test_id, Question.is_active == True)
).order_by(Question.order_index).all()
@staticmethod
def start_test(db: Session, user_id: int, test_id: int) -> TestResult:
"""
Начало прохождения теста пользователем
Args:
db: Сессия базы данных
user_id: ID пользователя
test_id: ID теста
Returns:
TestResult: Созданная запись результата теста
"""
# Проверяем, есть ли незавершенный тест
existing_result = db.query(TestResult).filter(
and_(
TestResult.user_id == user_id,
TestResult.test_id == test_id,
TestResult.is_completed == False
)
).first()
if existing_result:
return existing_result
# Создаем новый результат теста
test_result = TestResult(
user_id=user_id,
test_id=test_id,
is_completed=False
)
db.add(test_result)
db.commit()
db.refresh(test_result)
return test_result
@staticmethod
def save_answer(
db: Session,
test_result_id: int,
question_id: int,
selected_option_id: int
) -> UserAnswer:
"""
Сохранение ответа пользователя на вопрос
Args:
db: Сессия базы данных
test_result_id: ID результата теста
question_id: ID вопроса
selected_option_id: ID выбранного варианта ответа
Returns:
UserAnswer: Созданная запись ответа
"""
# Проверяем, есть ли уже ответ на этот вопрос
existing_answer = db.query(UserAnswer).filter(
and_(
UserAnswer.test_result_id == test_result_id,
UserAnswer.question_id == question_id
)
).first()
if existing_answer:
# Обновляем существующий ответ
existing_answer.selected_option_id = selected_option_id
db.commit()
return existing_answer
# Создаем новый ответ
user_answer = UserAnswer(
test_result_id=test_result_id,
question_id=question_id,
selected_option_id=selected_option_id
)
db.add(user_answer)
db.commit()
db.refresh(user_answer)
return user_answer
@staticmethod
def complete_test(db: Session, test_result_id: int) -> TestResult:
"""
Завершение теста и подсчет результатов
Args:
db: Сессия базы данных
test_result_id: ID результата теста
Returns:
TestResult: Обновленная запись результата с подсчитанными данными
"""
from datetime import datetime
test_result = db.query(TestResult).filter(
TestResult.id == test_result_id
).first()
if not test_result:
raise ValueError(f"TestResult с ID {test_result_id} не найден")
# Получаем все ответы пользователя для этого теста
answers = db.query(UserAnswer).filter(
UserAnswer.test_result_id == test_result_id
).all()
# Подсчитываем результаты по категориям
category_scores = {}
for answer in answers:
option = db.query(AnswerOption).filter(
AnswerOption.id == answer.selected_option_id
).first()
if option and option.category:
category = option.category
category_scores[category] = category_scores.get(category, 0) + 1
# Сохраняем результаты в JSON формате
test_result.result_data = json.dumps(category_scores, ensure_ascii=False)
test_result.completed_at = datetime.now()
test_result.is_completed = True
db.commit()
db.refresh(test_result)
return test_result
@staticmethod
def get_test_results(db: Session, test_result_id: int) -> Dict[str, Any]:
"""
Получение и форматирование результатов теста
Args:
db: Сессия базы данных
test_result_id: ID результата теста
Returns:
Dict: Словарь с результатами и процентами
"""
test_result = db.query(TestResult).filter(
TestResult.id == test_result_id
).first()
if not test_result or not test_result.result_data:
return {}
# Парсим результаты
category_scores = json.loads(test_result.result_data)
# Получаем максимальные возможные баллы для каждой категории
questions = TestService.get_test_questions(db, test_result.test_id)
max_scores = {}
for question in questions:
for option in question.answer_options:
if option.category:
max_scores[option.category] = max_scores.get(option.category, 0) + 1
# Вычисляем проценты
results = {}
for category, score in category_scores.items():
max_score = max_scores.get(category, 1)
percentage = (score / max_score) * 100
results[category] = {
'score': score,
'max_score': max_score,
'percentage': round(percentage, 1)
}
return results
@staticmethod
def get_user_test_history(db: Session, user_id: int) -> List[TestResult]:
"""Получение истории тестов пользователя"""
return db.query(TestResult).filter(
and_(TestResult.user_id == user_id, TestResult.is_completed == True)
).order_by(TestResult.completed_at.desc()).all()
class QuestionService:
"""Сервис для работы с вопросами"""
@staticmethod
def get_question_with_options(db: Session, question_id: int) -> Optional[Question]:
"""
Получение вопроса со всеми вариантами ответов и изображением
Args:
db: Сессия базы данных
question_id: ID вопроса
Returns:
Question: Вопрос с загруженными связями или None
"""
return db.query(Question).filter(Question.id == question_id).first()
@staticmethod
def format_question_for_bot(db: Session, question: Question) -> Dict[str, Any]:
"""
Форматирование вопроса для отправки в бот
Args:
db: Сессия базы данных
question: Объект вопроса
Returns:
Dict: Сформатированные данные вопроса
"""
# Получаем варианты ответов
options = db.query(AnswerOption).filter(
AnswerOption.question_id == question.id
).order_by(AnswerOption.order_index).all()
# Получаем данные изображения, если есть
image_data = None
if question.image_id:
image_info = ImageService.get_image_data(db, question.image_id)
if image_info:
image_data = {
'data': image_info[0],
'content_type': image_info[1],
'filename': image_info[2]
}
return {
'id': question.id,
'text': question.question_text,
'order_index': question.order_index,
'image': image_data,
'options': [
{
'id': option.id,
'text': option.option_text,
'category': option.category,
'order_index': option.order_index
}
for option in options
]
}