struckt
This commit is contained in:
43
app.api/Dockerfile
Normal file
43
app.api/Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
||||
# Dockerfile для API сервера
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Устанавливаем рабочую директорию
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем системные зависимости
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копируем файлы зависимостей
|
||||
COPY requirements.txt* ./
|
||||
COPY pyproject.toml* ./
|
||||
|
||||
# Устанавливаем Python зависимости
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi
|
||||
RUN if [ -f pyproject.toml ]; then pip install --no-cache-dir -e .; fi
|
||||
|
||||
# Устанавливаем основные зависимости для FastAPI
|
||||
RUN pip install --no-cache-dir \
|
||||
fastapi \
|
||||
uvicorn[standard] \
|
||||
sqlalchemy \
|
||||
python-multipart \
|
||||
python-dotenv
|
||||
|
||||
# Копируем исходный код
|
||||
COPY . .
|
||||
|
||||
# Создаем директорию для данных
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Экспонируем порт
|
||||
EXPOSE 8000
|
||||
|
||||
# Проверка здоровья
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Команда запуска
|
||||
CMD ["python", "run_api.py"]
|
||||
27
app.api/__init__.py
Normal file
27
app.api/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
API пакет для Telegram Bot
|
||||
"""
|
||||
|
||||
from .main import app
|
||||
from .routers import users_router, messages_router, settings_router
|
||||
from .schemas import (
|
||||
UserCreate, UserUpdate, UserResponse,
|
||||
MessageCreate, MessageResponse,
|
||||
BotSettingCreate, BotSettingUpdate, BotSettingResponse
|
||||
)
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__all__ = [
|
||||
"app",
|
||||
"users_router",
|
||||
"messages_router",
|
||||
"settings_router",
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserResponse",
|
||||
"MessageCreate",
|
||||
"MessageResponse",
|
||||
"BotSettingCreate",
|
||||
"BotSettingUpdate",
|
||||
"BotSettingResponse"
|
||||
]
|
||||
37
app.api/config.py
Normal file
37
app.api/config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Конфигурация для API приложения
|
||||
"""
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Загрузка переменных окружения из .env файла
|
||||
load_dotenv()
|
||||
|
||||
class APIConfig:
|
||||
"""Конфигурация API приложения"""
|
||||
|
||||
# Основные настройки
|
||||
TITLE = "Telegram Bot API"
|
||||
DESCRIPTION = "API для взаимодействия с Telegram ботом и базой данных"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
# Настройки сервера
|
||||
HOST = os.getenv("API_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("API_PORT", "8000"))
|
||||
RELOAD = os.getenv("API_RELOAD", "true").lower() == "true"
|
||||
|
||||
# Настройки CORS
|
||||
CORS_ORIGINS = ["*"] # В продакшене стоит ограничить
|
||||
CORS_CREDENTIALS = True
|
||||
CORS_METHODS = ["*"]
|
||||
CORS_HEADERS = ["*"]
|
||||
|
||||
# Настройки логирования
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
# API префикс
|
||||
API_V1_PREFIX = "/api/v1"
|
||||
|
||||
# Глобальный экземпляр конфигурации
|
||||
config = APIConfig()
|
||||
102
app.api/database/README.md
Normal file
102
app.api/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
app.api/database/__init__.py
Normal file
37
app.api/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 ImageService, TestService, QuestionService
|
||||
|
||||
__all__ = [
|
||||
# Основные классы
|
||||
'Database',
|
||||
'DatabaseConfig',
|
||||
|
||||
# Глобальные объекты
|
||||
'db',
|
||||
|
||||
# Функции
|
||||
'get_db_session',
|
||||
'init_database',
|
||||
'test_db_connection',
|
||||
|
||||
# Модели
|
||||
'User',
|
||||
'Message',
|
||||
'BotSettings',
|
||||
|
||||
# Репозитории
|
||||
'UserRepository',
|
||||
'MessageRepository',
|
||||
'SettingsRepository',
|
||||
|
||||
# Сервисы
|
||||
'ImageService',
|
||||
'TestService',
|
||||
'QuestionService',
|
||||
]
|
||||
201
app.api/database/database.py
Normal file
201
app.api/database/database.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Модуль для подключения к базе данных 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:
|
||||
"""Проверка подключения к базе данных"""
|
||||
from sqlalchemy import text
|
||||
try:
|
||||
with self.get_session() as session:
|
||||
session.execute(text("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(max_retries: int = 5, delay: int = 2):
|
||||
"""
|
||||
Тестирование подключения к базе данных с повторными попытками
|
||||
|
||||
Args:
|
||||
max_retries: Максимальное количество попыток
|
||||
delay: Задержка между попытками в секундах
|
||||
"""
|
||||
import time
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if db.test_connection():
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Попытка {attempt + 1}/{max_retries} подключения не удалась: {e}")
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
logger.info(f"Ожидание {delay} секунд перед следующей попыткой...")
|
||||
time.sleep(delay)
|
||||
|
||||
logger.error(f"Не удалось подключиться к базе данных после {max_retries} попыток")
|
||||
return False
|
||||
|
||||
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
|
||||
271
app.api/database/load_images.py
Normal file
271
app.api/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
app.api/database/models.py
Normal file
208
app.api/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})>"
|
||||
334
app.api/database/repositories.py
Normal file
334
app.api/database/repositories.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Репозитории для работы с моделями базы данных системы тестирования
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestRepository:
|
||||
"""Репозиторий для работы с тестами"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create_test(
|
||||
self,
|
||||
title: str,
|
||||
description: str = None,
|
||||
category_id: int = None,
|
||||
is_active: bool = True,
|
||||
time_limit_minutes: int = None
|
||||
) -> 'Test':
|
||||
"""Создание нового теста"""
|
||||
from database.models import Test
|
||||
|
||||
test = Test(
|
||||
title=title,
|
||||
description=description,
|
||||
category_id=category_id,
|
||||
is_active=is_active,
|
||||
time_limit_minutes=time_limit_minutes
|
||||
)
|
||||
self.session.add(test)
|
||||
self.session.commit()
|
||||
self.session.refresh(test)
|
||||
return test
|
||||
|
||||
def get_test_by_id(self, test_id: int) -> Optional['Test']:
|
||||
"""Получение теста по ID"""
|
||||
from database.models import Test
|
||||
return self.session.query(Test).filter(Test.id == test_id).first()
|
||||
|
||||
def get_tests(
|
||||
self,
|
||||
is_active: bool = None,
|
||||
category_id: int = None,
|
||||
limit: int = None
|
||||
) -> List['Test']:
|
||||
"""Получение списка тестов с фильтрацией"""
|
||||
from database.models import Test
|
||||
|
||||
query = self.session.query(Test)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(Test.is_active == is_active)
|
||||
|
||||
if category_id is not None:
|
||||
query = query.filter(Test.category_id == category_id)
|
||||
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
return query.all()
|
||||
|
||||
def update_test(self, test_id: int, **kwargs) -> Optional['Test']:
|
||||
"""Обновление теста"""
|
||||
from database.models import Test
|
||||
|
||||
test = self.session.query(Test).filter(Test.id == test_id).first()
|
||||
if test:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(test, key):
|
||||
setattr(test, key, value)
|
||||
self.session.commit()
|
||||
self.session.refresh(test)
|
||||
return test
|
||||
|
||||
def delete_test(self, test_id: int) -> bool:
|
||||
"""Удаление теста"""
|
||||
from database.models import Test
|
||||
|
||||
test = self.session.query(Test).filter(Test.id == test_id).first()
|
||||
if test:
|
||||
self.session.delete(test)
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class QuestionRepository:
|
||||
"""Репозиторий для работы с вопросами"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create_question(
|
||||
self,
|
||||
test_id: int,
|
||||
question_text: str,
|
||||
question_type: str = "single_choice",
|
||||
order_number: int = 1,
|
||||
points: int = 1,
|
||||
image_id: int = None
|
||||
) -> 'Question':
|
||||
"""Создание нового вопроса"""
|
||||
from database.models import Question
|
||||
|
||||
question = Question(
|
||||
test_id=test_id,
|
||||
question_text=question_text,
|
||||
question_type=question_type,
|
||||
order_number=order_number,
|
||||
points=points,
|
||||
image_id=image_id
|
||||
)
|
||||
self.session.add(question)
|
||||
self.session.commit()
|
||||
self.session.refresh(question)
|
||||
return question
|
||||
|
||||
def get_question_by_id(self, question_id: int) -> Optional['Question']:
|
||||
"""Получение вопроса по ID"""
|
||||
from database.models import Question
|
||||
return self.session.query(Question).filter(Question.id == question_id).first()
|
||||
|
||||
def get_questions_by_test_id(self, test_id: int) -> List['Question']:
|
||||
"""Получение всех вопросов для теста"""
|
||||
from database.models import Question
|
||||
return self.session.query(Question).filter(
|
||||
Question.test_id == test_id
|
||||
).order_by(Question.order_number).all()
|
||||
|
||||
def update_question(self, question_id: int, **kwargs) -> Optional['Question']:
|
||||
"""Обновление вопроса"""
|
||||
from database.models import Question
|
||||
|
||||
question = self.session.query(Question).filter(Question.id == question_id).first()
|
||||
if question:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(question, key):
|
||||
setattr(question, key, value)
|
||||
self.session.commit()
|
||||
self.session.refresh(question)
|
||||
return question
|
||||
|
||||
def delete_question(self, question_id: int) -> bool:
|
||||
"""Удаление вопроса"""
|
||||
from database.models import Question
|
||||
|
||||
question = self.session.query(Question).filter(Question.id == question_id).first()
|
||||
if question:
|
||||
self.session.delete(question)
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
358
app.api/database/services.py
Normal file
358
app.api/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
|
||||
]
|
||||
}
|
||||
106
app.api/main.py
Normal file
106
app.api/main.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
FastAPI приложение для взаимодействия с Telegram ботом
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Добавляем путь к текущей директории
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from database.database import db, get_db_session, init_database, test_db_connection
|
||||
from database.models import User, Message, BotSettings
|
||||
from routers import (
|
||||
users_router, messages_router, settings_router,
|
||||
test_categories_router, tests_router, questions_router,
|
||||
answer_options_router, images_router
|
||||
)
|
||||
from schemas import HealthCheckResponse, RootResponse
|
||||
from config import config
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Управление жизненным циклом приложения"""
|
||||
# Инициализация при запуске
|
||||
logger.info("Запуск FastAPI приложения...")
|
||||
|
||||
# Проверка подключения к БД
|
||||
if not test_db_connection():
|
||||
logger.error("Не удалось подключиться к базе данных!")
|
||||
raise Exception("Database connection failed")
|
||||
|
||||
# Создание таблиц если они не существуют
|
||||
try:
|
||||
init_database()
|
||||
logger.info("База данных инициализирована")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка инициализации базы данных: {e}")
|
||||
raise
|
||||
|
||||
yield
|
||||
|
||||
# Очистка при завершении
|
||||
logger.info("Завершение работы FastAPI приложения...")
|
||||
db.close()
|
||||
|
||||
# Создание FastAPI приложения
|
||||
app = FastAPI(
|
||||
title=config.TITLE,
|
||||
description=config.DESCRIPTION,
|
||||
version=config.VERSION,
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Настройка CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=config.CORS_ORIGINS,
|
||||
allow_credentials=config.CORS_CREDENTIALS,
|
||||
allow_methods=config.CORS_METHODS,
|
||||
allow_headers=config.CORS_HEADERS,
|
||||
)
|
||||
|
||||
# Подключение роутеров
|
||||
app.include_router(users_router, prefix=config.API_V1_PREFIX)
|
||||
app.include_router(messages_router, prefix=config.API_V1_PREFIX)
|
||||
app.include_router(settings_router, prefix=config.API_V1_PREFIX)
|
||||
|
||||
# Подключение роутеров для системы тестирования
|
||||
app.include_router(test_categories_router, prefix=config.API_V1_PREFIX)
|
||||
app.include_router(tests_router, prefix=config.API_V1_PREFIX)
|
||||
app.include_router(questions_router, prefix=config.API_V1_PREFIX)
|
||||
app.include_router(answer_options_router, prefix=config.API_V1_PREFIX)
|
||||
app.include_router(images_router, prefix=config.API_V1_PREFIX)
|
||||
|
||||
@app.get("/", response_model=RootResponse)
|
||||
async def root():
|
||||
"""Корневой эндпоинт"""
|
||||
return RootResponse(
|
||||
message=config.TITLE,
|
||||
version=config.VERSION,
|
||||
status="running"
|
||||
)
|
||||
|
||||
@app.get("/health", response_model=HealthCheckResponse)
|
||||
async def health_check():
|
||||
"""Проверка состояния приложения"""
|
||||
db_status = test_db_connection()
|
||||
return HealthCheckResponse(
|
||||
status="healthy" if db_status else "unhealthy",
|
||||
database="connected" if db_status else "disconnected"
|
||||
)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host=config.HOST, port=config.PORT, reload=config.RELOAD)
|
||||
460
app.api/routers.py
Normal file
460
app.api/routers.py
Normal file
@@ -0,0 +1,460 @@
|
||||
"""
|
||||
API маршруты для работы с пользователями, сообщениями и настройками
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Добавляем путь к корневой директории проекта
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database.database import db
|
||||
from database.models import User, Message, BotSettings
|
||||
from api.schemas import (
|
||||
UserCreate, UserUpdate, UserResponse,
|
||||
MessageCreate, MessageResponse,
|
||||
BotSettingCreate, BotSettingUpdate, BotSettingResponse,
|
||||
MessageResponseModel, ErrorResponse
|
||||
)
|
||||
|
||||
# Зависимость для получения сессии БД
|
||||
def get_db():
|
||||
"""Получение сессии базы данных"""
|
||||
session = db.get_session_sync()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# Роутер для работы с пользователями
|
||||
users_router = APIRouter(prefix="/users", tags=["Users"])
|
||||
|
||||
@users_router.get("/{telegram_id}", response_model=UserResponse)
|
||||
async def get_user_by_telegram_id(telegram_id: int, db_session: Session = Depends(get_db)):
|
||||
"""Получение пользователя по Telegram ID"""
|
||||
user = db_session.query(User).filter(User.telegram_id == telegram_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
return user
|
||||
|
||||
@users_router.get("/", response_model=List[UserResponse])
|
||||
async def get_all_users(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
is_active: Optional[bool] = Query(default=None),
|
||||
db_session: Session = Depends(get_db)
|
||||
):
|
||||
"""Получение списка всех пользователей с фильтрацией"""
|
||||
query = db_session.query(User)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(User.is_active == is_active)
|
||||
|
||||
users = query.offset(offset).limit(limit).all()
|
||||
return users
|
||||
|
||||
@users_router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(user_data: UserCreate, db_session: Session = Depends(get_db)):
|
||||
"""Создание нового пользователя"""
|
||||
# Проверяем, существует ли уже пользователь
|
||||
existing_user = db_session.query(User).filter(User.telegram_id == user_data.telegram_id).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пользователь уже существует"
|
||||
)
|
||||
|
||||
# Создаем нового пользователя
|
||||
new_user = User(**user_data.model_dump())
|
||||
db_session.add(new_user)
|
||||
db_session.commit()
|
||||
db_session.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
|
||||
@users_router.put("/{telegram_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
telegram_id: int,
|
||||
user_data: UserUpdate,
|
||||
db_session: Session = Depends(get_db)
|
||||
):
|
||||
"""Обновление данных пользователя"""
|
||||
user = db_session.query(User).filter(User.telegram_id == telegram_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
# Обновляем только переданные поля
|
||||
update_data = user_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
db_session.commit()
|
||||
db_session.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
@users_router.delete("/{telegram_id}", response_model=MessageResponseModel)
|
||||
async def delete_user(telegram_id: int, db_session: Session = Depends(get_db)):
|
||||
"""Деактивация пользователя (мягкое удаление)"""
|
||||
user = db_session.query(User).filter(User.telegram_id == telegram_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
user.is_active = False
|
||||
db_session.commit()
|
||||
|
||||
return MessageResponseModel(message="Пользователь деактивирован")
|
||||
|
||||
# Роутер для работы с сообщениями
|
||||
messages_router = APIRouter(prefix="/messages", tags=["Messages"])
|
||||
|
||||
@messages_router.get("/", response_model=List[MessageResponse])
|
||||
async def get_messages(
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
message_type: Optional[str] = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db_session: Session = Depends(get_db)
|
||||
):
|
||||
"""Получение сообщений с фильтрацией"""
|
||||
query = db_session.query(Message)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(Message.user_id == user_id)
|
||||
|
||||
if message_type:
|
||||
query = query.filter(Message.message_type == message_type)
|
||||
|
||||
messages = query.order_by(Message.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return messages
|
||||
|
||||
@messages_router.get("/{message_id}", response_model=MessageResponse)
|
||||
async def get_message(message_id: int, db_session: Session = Depends(get_db)):
|
||||
"""Получение конкретного сообщения по ID"""
|
||||
message = db_session.query(Message).filter(Message.id == message_id).first()
|
||||
if not message:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Сообщение не найдено"
|
||||
)
|
||||
return message
|
||||
|
||||
@messages_router.post("/", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_message(message_data: MessageCreate, db_session: Session = Depends(get_db)):
|
||||
"""Создание нового сообщения"""
|
||||
# Проверяем, существует ли пользователь
|
||||
user = db_session.query(User).filter(User.id == message_data.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
new_message = Message(**message_data.model_dump())
|
||||
db_session.add(new_message)
|
||||
db_session.commit()
|
||||
db_session.refresh(new_message)
|
||||
|
||||
return new_message
|
||||
|
||||
# Роутер для работы с настройками бота
|
||||
settings_router = APIRouter(prefix="/settings", tags=["Bot Settings"])
|
||||
|
||||
@settings_router.get("/", response_model=dict)
|
||||
async def get_all_settings(db_session: Session = Depends(get_db)):
|
||||
"""Получение всех настроек бота в виде словаря"""
|
||||
settings = db_session.query(BotSettings).all()
|
||||
return {setting.key: setting.value for setting in settings}
|
||||
|
||||
@settings_router.get("/list", response_model=List[BotSettingResponse])
|
||||
async def get_all_settings_list(db_session: Session = Depends(get_db)):
|
||||
"""Получение всех настроек бота в виде списка объектов"""
|
||||
settings = db_session.query(BotSettings).all()
|
||||
return settings
|
||||
|
||||
@settings_router.get("/{key}", response_model=BotSettingResponse)
|
||||
async def get_setting(key: str, db_session: Session = Depends(get_db)):
|
||||
"""Получение конкретной настройки по ключу"""
|
||||
setting = db_session.query(BotSettings).filter(BotSettings.key == key).first()
|
||||
if not setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Настройка не найдена"
|
||||
)
|
||||
return setting
|
||||
|
||||
@settings_router.post("/", response_model=BotSettingResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_or_update_setting(
|
||||
setting_data: BotSettingCreate,
|
||||
db_session: Session = Depends(get_db)
|
||||
):
|
||||
"""Создание или обновление настройки"""
|
||||
existing_setting = db_session.query(BotSettings).filter(BotSettings.key == setting_data.key).first()
|
||||
|
||||
if existing_setting:
|
||||
# Обновляем существующую настройку
|
||||
update_data = setting_data.model_dump(exclude={"key"})
|
||||
for field, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(existing_setting, field, value)
|
||||
db_session.commit()
|
||||
db_session.refresh(existing_setting)
|
||||
return existing_setting
|
||||
else:
|
||||
# Создаем новую настройку
|
||||
new_setting = BotSettings(**setting_data.model_dump())
|
||||
db_session.add(new_setting)
|
||||
db_session.commit()
|
||||
db_session.refresh(new_setting)
|
||||
return new_setting
|
||||
|
||||
@settings_router.put("/{key}", response_model=BotSettingResponse)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
setting_data: BotSettingUpdate,
|
||||
db_session: Session = Depends(get_db)
|
||||
):
|
||||
"""Обновление существующей настройки"""
|
||||
setting = db_session.query(BotSettings).filter(BotSettings.key == key).first()
|
||||
if not setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Настройка не найдена"
|
||||
)
|
||||
|
||||
update_data = setting_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(setting, field, value)
|
||||
|
||||
db_session.commit()
|
||||
db_session.refresh(setting)
|
||||
|
||||
return setting
|
||||
|
||||
@settings_router.delete("/{key}", response_model=MessageResponseModel)
|
||||
async def delete_setting(key: str, db_session: Session = Depends(get_db)):
|
||||
"""Удаление настройки"""
|
||||
setting = db_session.query(BotSettings).filter(BotSettings.key == key).first()
|
||||
if not setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Настройка не найдена"
|
||||
)
|
||||
|
||||
db_session.delete(setting)
|
||||
db_session.commit()
|
||||
|
||||
return MessageResponseModel(message=f"Настройка '{key}' удалена")
|
||||
|
||||
|
||||
# Добавляем импорты для работы с тестами
|
||||
from database.models import Test, TestCategory, Question, AnswerOption, Image
|
||||
from database.services import ImageService, TestService, QuestionService
|
||||
from database.repositories import TestRepository, QuestionRepository, ImageRepository
|
||||
from api.schemas import (
|
||||
TestCategoryCreate, TestCategoryResponse, TestCreate, TestResponse,
|
||||
QuestionCreate, QuestionResponse, AnswerOptionCreate, AnswerOptionResponse,
|
||||
ImageUploadResponse
|
||||
)
|
||||
from fastapi import UploadFile, File
|
||||
|
||||
# Роутер для работы с категориями тестов
|
||||
test_categories_router = APIRouter(prefix="/test-categories", tags=["Test Categories"])
|
||||
|
||||
@test_categories_router.post("/", response_model=TestCategoryResponse)
|
||||
async def create_test_category(
|
||||
category_data: TestCategoryCreate,
|
||||
db_session: Session = Depends(get_db)
|
||||
):
|
||||
"""Создание новой категории тестов"""
|
||||
category = TestCategory(
|
||||
name=category_data.name,
|
||||
description=category_data.description
|
||||
)
|
||||
db_session.add(category)
|
||||
db_session.commit()
|
||||
db_session.refresh(category)
|
||||
return category
|
||||
|
||||
@test_categories_router.get("/", response_model=List[TestCategoryResponse])
|
||||
async def get_test_categories(db_session: Session = Depends(get_db)):
|
||||
"""Получение всех категорий тестов"""
|
||||
categories = db_session.query(TestCategory).all()
|
||||
return categories
|
||||
|
||||
# Роутер для работы с тестами
|
||||
tests_router = APIRouter(prefix="/tests", tags=["Tests"])
|
||||
|
||||
@tests_router.post("/", response_model=TestResponse)
|
||||
async def create_test(
|
||||
test_data: TestCreate,
|
||||
db_session: Session = Depends(get_db)
|
||||
):
|
||||
"""Создание нового теста"""
|
||||
test_repo = TestRepository(db_session)
|
||||
test = test_repo.create_test(
|
||||
title=test_data.title,
|
||||
description=test_data.description,
|
||||
category_id=test_data.category_id,
|
||||
is_active=test_data.is_active,
|
||||
time_limit_minutes=test_data.time_limit_minutes
|
||||
)
|
||||
return test
|
||||
|
||||
@tests_router.get("/", response_model=List[TestResponse])
|
||||
async def get_tests(
|
||||
is_active: Optional[bool] = Query(default=None),
|
||||
category_id: Optional[int] = Query(default=None),
|
||||
db_session: Session = Depends(get_db)
|
||||
):
|
||||
"""Получение списка тестов с фильтрацией"""
|
||||
test_repo = TestRepository(db_session)
|
||||
tests = test_repo.get_tests(is_active=is_active, category_id=category_id)
|
||||
return tests
|
||||
|
||||
@tests_router.get("/{test_id}", response_model=TestResponse)
|
||||
async def get_test_by_id(test_id: int, db_session: Session = Depends(get_db)):
|
||||
"""Получение теста по ID"""
|
||||
test_repo = TestRepository(db_session)
|
||||
test = test_repo.get_test_by_id(test_id)
|
||||
if not test:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Тест не найден"
|
||||
)
|
||||
return test
|
||||
|
||||
# Роутер для работы с вопросами
|
||||
questions_router = APIRouter(prefix="/questions", tags=["Questions"])
|
||||
|
||||
@questions_router.post("/", response_model=QuestionResponse)
|
||||
async def create_question(
|
||||
question_data: QuestionCreate,
|
||||
db_session: Session = Depends(get_db)
|
||||
):
|
||||
"""Создание нового вопроса"""
|
||||
question_repo = QuestionRepository(db_session)
|
||||
question = question_repo.create_question(
|
||||
test_id=question_data.test_id,
|
||||
question_text=question_data.question_text,
|
||||
question_type=question_data.question_type,
|
||||
order_number=question_data.order_number,
|
||||
points=question_data.points,
|
||||
image_id=question_data.image_id
|
||||
)
|
||||
return question
|
||||
|
||||
@questions_router.get("/test/{test_id}", response_model=List[QuestionResponse])
|
||||
async def get_questions_by_test(test_id: int, db_session: Session = Depends(get_db)):
|
||||
"""Получение всех вопросов для теста"""
|
||||
question_repo = QuestionRepository(db_session)
|
||||
questions = question_repo.get_questions_by_test_id(test_id)
|
||||
return questions
|
||||
|
||||
@questions_router.get("/{question_id}", response_model=QuestionResponse)
|
||||
async def get_question_by_id(question_id: int, db_session: Session = Depends(get_db)):
|
||||
"""Получение вопроса по ID"""
|
||||
question_repo = QuestionRepository(db_session)
|
||||
question = question_repo.get_question_by_id(question_id)
|
||||
if not question:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Вопрос не найден"
|
||||
)
|
||||
return question
|
||||
|
||||
# Роутер для работы с вариантами ответов
|
||||
answer_options_router = APIRouter(prefix="/answer-options", tags=["Answer Options"])
|
||||
|
||||
@answer_options_router.post("/", response_model=AnswerOptionResponse)
|
||||
async def create_answer_option(
|
||||
option_data: AnswerOptionCreate,
|
||||
db_session: Session = Depends(get_db)
|
||||
):
|
||||
"""Создание нового варианта ответа"""
|
||||
option = AnswerOption(
|
||||
question_id=option_data.question_id,
|
||||
option_text=option_data.option_text,
|
||||
is_correct=option_data.is_correct,
|
||||
order_number=option_data.order_number
|
||||
)
|
||||
db_session.add(option)
|
||||
db_session.commit()
|
||||
db_session.refresh(option)
|
||||
return option
|
||||
|
||||
@answer_options_router.get("/question/{question_id}", response_model=List[AnswerOptionResponse])
|
||||
async def get_answer_options_by_question(question_id: int, db_session: Session = Depends(get_db)):
|
||||
"""Получение всех вариантов ответов для вопроса"""
|
||||
options = db_session.query(AnswerOption).filter(
|
||||
AnswerOption.question_id == question_id
|
||||
).order_by(AnswerOption.order_number).all()
|
||||
return options
|
||||
|
||||
# Роутер для работы с изображениями
|
||||
images_router = APIRouter(prefix="/images", tags=["Images"])
|
||||
|
||||
@images_router.post("/upload", response_model=ImageUploadResponse)
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
alt_text: Optional[str] = None,
|
||||
db_session: Session = Depends(get_db)
|
||||
):
|
||||
"""Загрузка изображения в базу данных"""
|
||||
# Проверяем тип файла
|
||||
if not file.content_type.startswith('image/'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Загружаемый файл должен быть изображением"
|
||||
)
|
||||
|
||||
# Читаем содержимое файла
|
||||
image_data = await file.read()
|
||||
|
||||
# Сохраняем в БД через сервис
|
||||
image = ImageService.save_image_to_db(
|
||||
db=db_session,
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
image_data=image_data,
|
||||
alt_text=alt_text
|
||||
)
|
||||
|
||||
return ImageUploadResponse(
|
||||
id=image.id,
|
||||
filename=image.filename,
|
||||
content_type=image.content_type,
|
||||
file_size=image.file_size
|
||||
)
|
||||
|
||||
@images_router.get("/{image_id}")
|
||||
async def get_image(image_id: int, db_session: Session = Depends(get_db)):
|
||||
"""Получение изображения по ID"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
image_repo = ImageRepository(db_session)
|
||||
image = image_repo.get_image_by_id(image_id)
|
||||
|
||||
if not image:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Изображение не найдено"
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=image.image_data,
|
||||
media_type=image.content_type,
|
||||
headers={"Content-Disposition": f"inline; filename={image.filename}"}
|
||||
)
|
||||
35
app.api/run_server.py
Normal file
35
app.api/run_server.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Точка входа для API сервера
|
||||
API - единственный компонент, который имеет доступ к БД
|
||||
"""
|
||||
|
||||
import uvicorn
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Добавляем путь к базе данных
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from config import config
|
||||
|
||||
def main():
|
||||
"""Запуск API сервера"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("Запуск API сервера...")
|
||||
logger.info(f"Конфигурация: {config.APP_ENV}")
|
||||
|
||||
from main import app
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=int(os.getenv('API_PORT', 8000)),
|
||||
reload=config.DEBUG,
|
||||
log_level=config.LOG_LEVEL.lower()
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
208
app.api/schemas.py
Normal file
208
app.api/schemas.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Pydantic схемы для валидации данных API
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
# Базовые схемы для пользователей
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Базовая схема пользователя"""
|
||||
telegram_id: int = Field(..., description="Telegram ID пользователя")
|
||||
username: Optional[str] = Field(None, max_length=255, description="Username в Telegram")
|
||||
first_name: Optional[str] = Field(None, max_length=255, description="Имя пользователя")
|
||||
last_name: Optional[str] = Field(None, max_length=255, description="Фамилия пользователя")
|
||||
language_code: str = Field(default="ru", max_length=10, description="Код языка")
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Схема для создания пользователя"""
|
||||
pass
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Схема для обновления пользователя"""
|
||||
username: Optional[str] = Field(None, max_length=255)
|
||||
first_name: Optional[str] = Field(None, max_length=255)
|
||||
last_name: Optional[str] = Field(None, max_length=255)
|
||||
language_code: Optional[str] = Field(None, max_length=10)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class UserResponse(UserBase):
|
||||
"""Схема ответа с данными пользователя"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
# Базовые схемы для сообщений
|
||||
|
||||
class MessageBase(BaseModel):
|
||||
"""Базовая схема сообщения"""
|
||||
telegram_message_id: int = Field(..., description="ID сообщения в Telegram")
|
||||
user_id: int = Field(..., description="ID пользователя в нашей БД")
|
||||
text: Optional[str] = Field(None, description="Текст сообщения")
|
||||
message_type: str = Field(default="text", max_length=50, description="Тип сообщения")
|
||||
|
||||
class MessageCreate(MessageBase):
|
||||
"""Схема для создания сообщения"""
|
||||
pass
|
||||
|
||||
class MessageResponse(MessageBase):
|
||||
"""Схема ответа с данными сообщения"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
# Базовые схемы для настроек бота
|
||||
|
||||
class BotSettingBase(BaseModel):
|
||||
"""Базовая схема настройки бота"""
|
||||
key: str = Field(..., max_length=255, description="Ключ настройки")
|
||||
value: Optional[str] = Field(None, description="Значение настройки")
|
||||
description: Optional[str] = Field(None, description="Описание настройки")
|
||||
|
||||
class BotSettingCreate(BotSettingBase):
|
||||
"""Схема для создания настройки"""
|
||||
pass
|
||||
|
||||
class BotSettingUpdate(BaseModel):
|
||||
"""Схема для обновления настройки"""
|
||||
value: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
class BotSettingResponse(BotSettingBase):
|
||||
"""Схема ответа с данными настройки"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
# Схемы для общих ответов API
|
||||
|
||||
class MessageResponseModel(BaseModel):
|
||||
"""Общая схема ответа с сообщением"""
|
||||
message: str
|
||||
status: str = "success"
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Схема ответа с ошибкой"""
|
||||
detail: str
|
||||
status: str = "error"
|
||||
|
||||
class HealthCheckResponse(BaseModel):
|
||||
"""Схема ответа проверки здоровья"""
|
||||
status: str
|
||||
database: str
|
||||
|
||||
class RootResponse(BaseModel):
|
||||
"""Схема корневого ответа"""
|
||||
message: str
|
||||
version: str
|
||||
status: str
|
||||
|
||||
# Схемы для пагинации
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""Параметры пагинации"""
|
||||
limit: int = Field(default=100, ge=1, le=1000, description="Количество элементов на странице")
|
||||
offset: int = Field(default=0, ge=0, description="Смещение")
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
"""Схема ответа с пагинацией"""
|
||||
items: list
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
has_next: bool
|
||||
has_prev: bool
|
||||
|
||||
# Схемы для работы с тестами
|
||||
|
||||
class TestCategoryCreate(BaseModel):
|
||||
"""Схема для создания категории тестов"""
|
||||
name: str = Field(..., max_length=100, description="Название категории")
|
||||
description: Optional[str] = Field(None, description="Описание категории")
|
||||
|
||||
class TestCategoryResponse(BaseModel):
|
||||
"""Схема ответа с категорией тестов"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class TestCreate(BaseModel):
|
||||
"""Схема для создания теста"""
|
||||
title: str = Field(..., max_length=200, description="Название теста")
|
||||
description: Optional[str] = Field(None, description="Описание теста")
|
||||
category_id: Optional[int] = Field(None, description="ID категории")
|
||||
is_active: bool = Field(default=True, description="Активен ли тест")
|
||||
time_limit_minutes: Optional[int] = Field(None, ge=1, description="Лимит времени в минутах")
|
||||
|
||||
class TestResponse(BaseModel):
|
||||
"""Схема ответа с тестом"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
title: str
|
||||
description: Optional[str]
|
||||
category_id: Optional[int]
|
||||
is_active: bool
|
||||
time_limit_minutes: Optional[int]
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class QuestionCreate(BaseModel):
|
||||
"""Схема для создания вопроса"""
|
||||
test_id: int = Field(..., description="ID теста")
|
||||
question_text: str = Field(..., description="Текст вопроса")
|
||||
question_type: str = Field(default="single_choice", description="Тип вопроса")
|
||||
order_number: int = Field(..., ge=1, description="Порядковый номер вопроса")
|
||||
points: int = Field(default=1, ge=0, description="Количество баллов за правильный ответ")
|
||||
image_id: Optional[int] = Field(None, description="ID изображения к вопросу")
|
||||
|
||||
class QuestionResponse(BaseModel):
|
||||
"""Схема ответа с вопросом"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
test_id: int
|
||||
question_text: str
|
||||
question_type: str
|
||||
order_number: int
|
||||
points: int
|
||||
image_id: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
class AnswerOptionCreate(BaseModel):
|
||||
"""Схема для создания варианта ответа"""
|
||||
question_id: int = Field(..., description="ID вопроса")
|
||||
option_text: str = Field(..., description="Текст варианта ответа")
|
||||
is_correct: bool = Field(..., description="Правильный ли этот вариант")
|
||||
order_number: int = Field(..., ge=1, description="Порядковый номер варианта")
|
||||
|
||||
class AnswerOptionResponse(BaseModel):
|
||||
"""Схема ответа с вариантом ответа"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
question_id: int
|
||||
option_text: str
|
||||
is_correct: bool
|
||||
order_number: int
|
||||
created_at: datetime
|
||||
|
||||
class ImageUploadResponse(BaseModel):
|
||||
"""Схема ответа при загрузке изображения"""
|
||||
id: int
|
||||
filename: str
|
||||
content_type: str
|
||||
file_size: int
|
||||
message: str = "Изображение успешно загружено"
|
||||
Reference in New Issue
Block a user