This commit is contained in:
2025-09-23 11:36:55 +05:00
parent 046b610a34
commit f2cf7aaecf
18 changed files with 2427 additions and 465 deletions

43
app.api/Dockerfile Normal file
View 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
View 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
View 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
View File

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

View File

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

View 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

View File

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

208
app.api/database/models.py Normal file
View File

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

View 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

View File

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

106
app.api/main.py Normal file
View 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
View 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
View 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
View 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 = "Изображение успешно загружено"