bd - api
This commit is contained in:
11
database/.env.example
Normal file
11
database/.env.example
Normal 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
102
database/README.md
Normal 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
37
database/__init__.py
Normal 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
180
database/database.py
Normal 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
145
database/example.py
Normal 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
271
database/load_images.py
Normal 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
208
database/models.py
Normal 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
185
database/repositories.py
Normal 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
|
||||
6
database/requirements.txt
Normal file
6
database/requirements.txt
Normal 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
358
database/services.py
Normal 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
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user