From f2cf7aaecfe15977ed9a64547bc9e83342f535c4 Mon Sep 17 00:00:00 2001 From: jze9 Date: Tue, 23 Sep 2025 11:36:55 +0500 Subject: [PATCH] struckt --- DOCKER_COMMANDS.md | 150 ---------- REFACTORING_REPORT.md | 130 -------- START_COMMANDS.md | 171 ----------- app.api/Dockerfile | 43 +++ app.api/__init__.py | 27 ++ app.api/config.py | 37 +++ app.api/database/README.md | 102 +++++++ app.api/database/__init__.py | 37 +++ app.api/database/database.py | 201 +++++++++++++ app.api/database/load_images.py | 271 +++++++++++++++++ app.api/database/models.py | 208 +++++++++++++ app.api/database/repositories.py | 334 +++++++++++++++++++++ app.api/database/services.py | 358 ++++++++++++++++++++++ app.api/main.py | 106 +++++++ app.api/routers.py | 460 +++++++++++++++++++++++++++++ app.api/run_server.py | 35 +++ app.api/schemas.py | 208 +++++++++++++ app.control-bot/docker-compose.yml | 14 - 18 files changed, 2427 insertions(+), 465 deletions(-) delete mode 100644 DOCKER_COMMANDS.md delete mode 100644 REFACTORING_REPORT.md delete mode 100644 START_COMMANDS.md create mode 100644 app.api/Dockerfile create mode 100644 app.api/__init__.py create mode 100644 app.api/config.py create mode 100644 app.api/database/README.md create mode 100644 app.api/database/__init__.py create mode 100644 app.api/database/database.py create mode 100644 app.api/database/load_images.py create mode 100644 app.api/database/models.py create mode 100644 app.api/database/repositories.py create mode 100644 app.api/database/services.py create mode 100644 app.api/main.py create mode 100644 app.api/routers.py create mode 100644 app.api/run_server.py create mode 100644 app.api/schemas.py delete mode 100644 app.control-bot/docker-compose.yml diff --git a/DOCKER_COMMANDS.md b/DOCKER_COMMANDS.md deleted file mode 100644 index 0fe593e..0000000 --- a/DOCKER_COMMANDS.md +++ /dev/null @@ -1,150 +0,0 @@ -# Docker команды для Telegram Bot Project - -## Новая архитектура: БОТ → API → БД - -### 🚀 Быстрый старт - -```bash -# Запуск только API + БД (для разработки) -docker-compose --profile api up -d - -# Запуск всех компонентов (API + БОТ + ВЕБ) -docker-compose --profile all up -d - -# Разработка с hot reload -docker-compose -f docker-compose.dev.yml up -d -``` - -### 📦 Отдельные компоненты - -```bash -# Только база данных -docker-compose up postgres -d - -# Только API сервер (обращается к БД) -docker-compose --profile api up api -d - -# Только бот (обращается к API) -docker-compose --profile bot up bot -d - -# Только веб-панель (обращается к API) -docker-compose --profile web up web -d -``` - -### 🔧 Управление - -```bash -# Остановка всех сервисов -docker-compose down - -# Пересборка и запуск -docker-compose --profile all up --build -d - -# Удаление данных -docker-compose down -v -``` - -### Логи и мониторинг -```bash -# Просмотр логов всех сервисов -docker-compose logs - -# Логи конкретного сервиса -docker-compose logs -f app -docker-compose logs -f postgres - -# Статус сервисов -docker-compose ps -``` - -### Управление сервисами -```bash -# Перезапуск сервиса -docker-compose restart app - -# Остановка конкретного сервиса -docker-compose stop app - -# Масштабирование (если нужно) -docker-compose up --scale app=2 -d -``` - -### Работа с контейнерами -```bash -# Выполнение команд в контейнере приложения -docker-compose exec app bash -docker-compose exec app python -c "from database.database import test_db_connection; print(test_db_connection())" - -# Подключение к PostgreSQL -docker-compose exec postgres psql -U postgres -d telegram_bot - -# Просмотр таблиц -docker-compose exec postgres psql -U postgres -d telegram_bot -c "\dt" -``` - -### Отладка и разработка -```bash -# Запуск в режиме разработки (с volume для кода) -docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d - -# Просмотр переменных окружения в контейнере -docker-compose exec app env - -# Проверка сетевого подключения -docker-compose exec app ping postgres -``` - -### Очистка и обслуживание -```bash -# Удаление неиспользуемых образов -docker image prune - -# Удаление всех остановленных контейнеров -docker container prune - -# Полная очистка Docker системы -docker system prune -a - -# Просмотр использования места -docker system df -``` - -## Готовые скрипты - -### Скрипт для быстрого запуска (start.sh) -```bash -#!/bin/bash -echo "Запуск Telegram Bot API..." -docker-compose up --build -d -echo "API доступно по адресу: http://localhost:8000" -echo "Документация: http://localhost:8000/docs" -``` - -### Скрипт для остановки (stop.sh) -```bash -#!/bin/bash -echo "Остановка Telegram Bot API..." -docker-compose down -echo "Сервисы остановлены" -``` - -### Скрипт для полного сброса (reset.sh) -```bash -#!/bin/bash -echo "ВНИМАНИЕ: Это удалит все данные из базы!" -read -p "Продолжить? (y/N) " -n 1 -r -echo -if [[ $REPLY =~ ^[Yy]$ ]]; then - docker-compose down -v - docker-compose up --build -d - echo "Система сброшена и перезапущена" -fi -``` - -## Полезные ссылки после запуска - -- API: http://localhost:8000 -- Swagger UI: http://localhost:8000/docs -- ReDoc: http://localhost:8000/redoc -- Health Check: http://localhost:8000/health -- pgAdmin: http://localhost:5050 (если запущен с профилем admin) \ No newline at end of file diff --git a/REFACTORING_REPORT.md b/REFACTORING_REPORT.md deleted file mode 100644 index c6bd019..0000000 --- a/REFACTORING_REPORT.md +++ /dev/null @@ -1,130 +0,0 @@ -# Отчет об исправлении минусов проекта - -## ✅ Исправленные проблемы - -### 1. **Безопасность - Токены и пароли** -**Было:** Токен бота и пароли БД захардкожены в коде -**Стало:** -- Создан `.env` файл для безопасного хранения секретов -- Создан `.env.template` как пример для разработчиков -- Добавлен `.env` в `.gitignore` -- Создан `bot_config.py` с валидацией конфигурации - -### 2. **Дублирование кода** -**Было:** Два файла `bot.py` и `bot2.py` с похожей функциональностью -**Стало:** -- Удален `bot2.py` -- Создан единый современный `bot.py` с использованием существующих репозиториев -- Старый код сохранен как `bot_old.py` для истории - -### 3. **Архитектура базы данных** -**Было:** Прямые SQL запросы в боте, несогласованность схем -**Стало:** -- Бот переписан для использования существующих репозиториев (`UserRepository`, `TestService`, `QuestionService`) -- Убраны прямые SQL запросы -- Используется единая схема через SQLAlchemy модели - -### 4. **Обработка ошибок** -**Было:** Минимальная обработка ошибок -**Стало:** -- Добавлены try-catch блоки во всех критических местах -- Настроено профессиональное логирование -- Graceful handling ошибок без краха бота - -### 5. **Точка входа** -**Было:** Пустой `main.py` -**Стало:** -- Полноценный `main.py` с поддержкой режимов запуска: - - `--mode bot` - только бот - - `--mode api` - только API - - `--mode both` - бот + API одновременно - -### 6. **Docker конфигурация** -**Было:** Хардкод значений в docker-compose -**Стало:** -- Поддержка переменных окружения из `.env` -- Отдельные сервисы для бота и API -- Конфигурация для разработки (`docker-compose.dev.yml`) -- Профили для гибкого запуска - -## 🏗️ Новая архитектура - -### Файловая структура -``` -bot-telegram/ -├── .env # Секреты (не в git) -├── .env.template # Шаблон переменных -├── main.py # Основная точка входа -├── bot.py # Современный бот с репозиториями -├── bot_config.py # Конфигурация с валидацией -├── bot_old.py # Резервная копия -├── START_COMMANDS.md # Инструкции по запуску -├── api/ # FastAPI приложение -├── database/ # SQLAlchemy модели и репозитории -└── docker-compose*.yml # Docker конфигурации -``` - -### Технологический стек -- ✅ **aiogram 3.x** для Telegram Bot API -- ✅ **SQLAlchemy** для работы с БД -- ✅ **FastAPI** для REST API -- ✅ **PostgreSQL** в Docker -- ✅ **python-dotenv** для переменных окружения -- ✅ **Структурированное логирование** - -### Режимы запуска -```bash -# Только бот -python main.py --mode bot - -# Только API -python main.py --mode api - -# Бот + API одновременно -python main.py --mode both -``` - -### Docker команды -```bash -# Простой запуск (только бот) -docker-compose up -d - -# С API -docker-compose --profile api up -d - -# Разработка с hot reload -docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d -``` - -## 🔧 Основные улучшения - -### Безопасность -- ❌ Секреты в коде → ✅ Переменные окружения -- ❌ Хардкод подключений → ✅ Конфигурируемые параметры - -### Код -- ❌ Дублирование логики → ✅ Единый современный бот -- ❌ Прямые SQL запросы → ✅ Репозитории и сервисы -- ❌ Минимальная обработка ошибок → ✅ Профессиональное логирование - -### Архитектура -- ❌ Смешанные схемы БД → ✅ Единая SQLAlchemy схема -- ❌ Монолитная структура → ✅ Модульная архитектура -- ❌ Сложный запуск → ✅ Гибкие режимы запуска - -### DevOps -- ❌ Хардкод в Docker → ✅ Переменные окружения -- ❌ Один режим работы → ✅ Отдельные сервисы (бот/API) -- ❌ Сложная разработка → ✅ Hot reload в dev режиме - -## 🚀 Готово к продакшену - -Проект теперь соответствует современным стандартам: -- 🔒 **Безопасность**: секреты в переменных окружения -- 📦 **Контейнеризация**: гибкая Docker конфигурация -- 🏗️ **Архитектура**: чистый код с репозиториями -- 📊 **Мониторинг**: структурированное логирование -- 🔧 **Развертывание**: множественные режимы запуска -- 🧪 **Разработка**: удобная среда разработки - -Все критические минусы исправлены! 🎉 \ No newline at end of file diff --git a/START_COMMANDS.md b/START_COMMANDS.md deleted file mode 100644 index 2405d9a..0000000 --- a/START_COMMANDS.md +++ /dev/null @@ -1,171 +0,0 @@ -# Команды для запуска проекта - -## Локальный запуск - -### 1. Установка зависимостей -```bash -# Создание виртуального окружения -python -m venv venv - -# Активация (Windows) -venv\Scripts\activate - -# Активация (Linux/Mac) -source venv/bin/activate - -# Установка зависимостей -pip install -r requirements.txt -# или при использовании uv: -uv sync -``` - -### 2. Настройка переменных окружения -```bash -# Скопируйте шаблон -cp .env.template .env - -# Отредактируйте .env файл, указав: -# - Токен телеграм бота -# - Параметры подключения к базе данных -``` - -### 3. Запуск приложения -```bash -# Только бот -python main.py --mode bot - -# Только API -python main.py --mode api - -# Бот + API одновременно -python main.py --mode both -``` - -## Docker запуск - -### 1. Простой запуск (только бот) -```bash -# Сборка и запуск -docker-compose up --build -d - -# Просмотр логов -docker-compose logs -f bot -``` - -### 2. Запуск с API -```bash -# Запуск бота и API -docker-compose --profile api up --build -d - -# Просмотр логов -docker-compose logs -f bot api -``` - -### 3. Разработка -```bash -# Запуск в режиме разработки с hot reload -docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d - -# Комбинированный режим (бот + API в одном контейнере) -docker-compose -f docker-compose.yml -f docker-compose.dev.yml --profile combined up --build -d -``` - -### 4. Управление контейнерами -```bash -# Остановка -docker-compose down - -# Остановка с удалением данных -docker-compose down -v - -# Пересборка без кэша -docker-compose build --no-cache - -# Просмотр статуса -docker-compose ps -``` - -## Полезные команды - -### База данных -```bash -# Подключение к PostgreSQL в контейнере -docker-compose exec postgres psql -U botuser -d botdb - -# Просмотр таблиц -docker-compose exec postgres psql -U botuser -d botdb -c "\dt" - -# Бэкап базы данных -docker-compose exec postgres pg_dump -U botuser botdb > backup.sql -``` - -### Логи и отладка -```bash -# Логи бота -docker-compose logs -f bot - -# Логи API -docker-compose logs -f api - -# Логи базы данных -docker-compose logs -f postgres - -# Подключение к контейнеру для отладки -docker-compose exec bot bash -``` - -### Тестирование API -```bash -# Проверка здоровья -curl http://localhost:8000/health - -# Swagger документация -# Откройте в браузере: http://localhost:8000/docs -``` - -## Переменные окружения - -Основные переменные в `.env` файле: - -```env -# Telegram Bot -TELEGRAM_BOT_TOKEN=your_bot_token_here - -# Database -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=botdb -DB_USER=botuser -DB_PASSWORD=your_secure_password - -# Application -APP_ENV=development -LOG_LEVEL=INFO -DEBUG=True - -# API (if needed) -API_HOST=0.0.0.0 -API_PORT=8000 -API_RELOAD=True -``` - -## Структура проекта после рефакторинга - -``` -bot-telegram/ -├── .env # Переменные окружения (не в git) -├── .env.template # Шаблон переменных -├── .gitignore # Исключения для git -├── main.py # Основная точка входа -├── bot.py # Современный Telegram бот -├── bot_config.py # Конфигурация бота -├── bot_old.py # Резервная копия старого бота -├── pyproject.toml # Зависимости Python -├── docker-compose.yml # Docker конфигурация -├── docker-compose.dev.yml # Docker для разработки -├── Dockerfile # Docker образ -├── START_COMMANDS.md # Этот файл -├── api/ # FastAPI приложение -├── database/ # Модели и репозитории -└── images/ # Изображения для тестов -``` \ No newline at end of file diff --git a/app.api/Dockerfile b/app.api/Dockerfile new file mode 100644 index 0000000..3084eec --- /dev/null +++ b/app.api/Dockerfile @@ -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"] \ No newline at end of file diff --git a/app.api/__init__.py b/app.api/__init__.py new file mode 100644 index 0000000..9003321 --- /dev/null +++ b/app.api/__init__.py @@ -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" +] \ No newline at end of file diff --git a/app.api/config.py b/app.api/config.py new file mode 100644 index 0000000..1a5b029 --- /dev/null +++ b/app.api/config.py @@ -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() \ No newline at end of file diff --git a/app.api/database/README.md b/app.api/database/README.md new file mode 100644 index 0000000..cbbca0e --- /dev/null +++ b/app.api/database/README.md @@ -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+ \ No newline at end of file diff --git a/app.api/database/__init__.py b/app.api/database/__init__.py new file mode 100644 index 0000000..ca2cf58 --- /dev/null +++ b/app.api/database/__init__.py @@ -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', +] \ No newline at end of file diff --git a/app.api/database/database.py b/app.api/database/database.py new file mode 100644 index 0000000..31c6b04 --- /dev/null +++ b/app.api/database/database.py @@ -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 \ No newline at end of file diff --git a/app.api/database/load_images.py b/app.api/database/load_images.py new file mode 100644 index 0000000..696cfad --- /dev/null +++ b/app.api/database/load_images.py @@ -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("❌ Неверный выбор") \ No newline at end of file diff --git a/app.api/database/models.py b/app.api/database/models.py new file mode 100644 index 0000000..51163b5 --- /dev/null +++ b/app.api/database/models.py @@ -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"" + +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"" + +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"" + + +# ===== МОДЕЛИ ДЛЯ СИСТЕМЫ ТЕСТИРОВАНИЯ ===== + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" \ No newline at end of file diff --git a/app.api/database/repositories.py b/app.api/database/repositories.py new file mode 100644 index 0000000..a428737 --- /dev/null +++ b/app.api/database/repositories.py @@ -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 \ No newline at end of file diff --git a/app.api/database/services.py b/app.api/database/services.py new file mode 100644 index 0000000..d7f0d0e --- /dev/null +++ b/app.api/database/services.py @@ -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 + ] + } \ No newline at end of file diff --git a/app.api/main.py b/app.api/main.py new file mode 100644 index 0000000..1f8781d --- /dev/null +++ b/app.api/main.py @@ -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) \ No newline at end of file diff --git a/app.api/routers.py b/app.api/routers.py new file mode 100644 index 0000000..48e955a --- /dev/null +++ b/app.api/routers.py @@ -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}"} + ) \ No newline at end of file diff --git a/app.api/run_server.py b/app.api/run_server.py new file mode 100644 index 0000000..d08ad8a --- /dev/null +++ b/app.api/run_server.py @@ -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() \ No newline at end of file diff --git a/app.api/schemas.py b/app.api/schemas.py new file mode 100644 index 0000000..da99b21 --- /dev/null +++ b/app.api/schemas.py @@ -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 = "Изображение успешно загружено" \ No newline at end of file diff --git a/app.control-bot/docker-compose.yml b/app.control-bot/docker-compose.yml deleted file mode 100644 index 7e4232e..0000000 --- a/app.control-bot/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - # Nginx веб-сервер для статических файлов - web: - image: nginx:alpine - container_name: bot-web-interface - ports: - - "3000:80" - volumes: - - ./web:/usr/share/nginx/html:ro - - ./nginx.conf:/etc/nginx/nginx.conf:ro - restart: unless-stopped - environment: - - NGINX_HOST=localhost - - NGINX_PORT=80 \ No newline at end of file