init
This commit is contained in:
7
new-planet-backend/.env
Normal file
7
new-planet-backend/.env
Normal file
@@ -0,0 +1,7 @@
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/newplanet
|
||||
REDIS_URL=redis://localhost:6379
|
||||
SECRET_KEY=your-secret-key-here
|
||||
GIGACHAT_API_KEY=your-gigachat-api-key
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
0
new-planet-backend/.gitignore
vendored
Normal file
0
new-planet-backend/.gitignore
vendored
Normal file
8
new-planet-backend/.idea/.gitignore
generated
vendored
Normal file
8
new-planet-backend/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
6
new-planet-backend/.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
new-planet-backend/.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
7
new-planet-backend/.idea/misc.xml
generated
Normal file
7
new-planet-backend/.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.12 (new-planet-backend)" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (new-planet-backend)" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
8
new-planet-backend/.idea/modules.xml
generated
Normal file
8
new-planet-backend/.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/new-planet-backend.iml" filepath="$PROJECT_DIR$/.idea/new-planet-backend.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
10
new-planet-backend/.idea/new-planet-backend.iml
generated
Normal file
10
new-planet-backend/.idea/new-planet-backend.iml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 (new-planet-backend)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
84
new-planet-backend/CLAUDE.md
Normal file
84
new-planet-backend/CLAUDE.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Инструкции для Claude AI
|
||||
|
||||
## Контекст проекта
|
||||
|
||||
Backend API для проекта "Новая Планета" - визуальное расписание для детей с расстройством аутистического спектра (РАС).
|
||||
|
||||
## Архитектура
|
||||
|
||||
- **FastAPI** - async веб-фреймворк
|
||||
- **SQLAlchemy 2.0** - async ORM
|
||||
- **PostgreSQL** - основная БД
|
||||
- **Redis** - кэширование
|
||||
- **GigaChat API** - ИИ-агент
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
app/
|
||||
├── api/v1/ # API endpoints
|
||||
├── core/ # Конфигурация, security, logging
|
||||
├── crud/ # CRUD операции
|
||||
├── db/ # Настройка БД
|
||||
├── middleware/ # Middleware (CORS, auth, rate limit)
|
||||
├── models/ # SQLAlchemy модели
|
||||
├── schemas/ # Pydantic схемы
|
||||
├── services/ # Бизнес-логика
|
||||
└── utils/ # Утилиты
|
||||
```
|
||||
|
||||
## Основные компоненты
|
||||
|
||||
### Модели
|
||||
- `User` - пользователи (CHILD, PARENT, EDUCATOR)
|
||||
- `Schedule` - расписания
|
||||
- `Task` - задачи в расписании
|
||||
- `Reward` - награды
|
||||
- `AIConversation` - история чата с ИИ
|
||||
|
||||
### Сервисы
|
||||
- `AuthService` - аутентификация (JWT)
|
||||
- `GigaChatService` - интеграция с GigaChat
|
||||
- `ChatService` - чат с ИИ-агентом
|
||||
- `ScheduleGenerator` - генерация расписаний через ИИ
|
||||
- `StorageService` - загрузка изображений (MinIO/S3)
|
||||
- `CacheService` - кэширование (Redis)
|
||||
|
||||
### API Endpoints
|
||||
- `/api/v1/auth/*` - аутентификация
|
||||
- `/api/v1/schedules/*` - расписания
|
||||
- `/api/v1/tasks/*` - задачи
|
||||
- `/api/v1/rewards/*` - награды
|
||||
- `/api/v1/ai/*` - ИИ функции
|
||||
- `/api/v1/images/*` - изображения
|
||||
- `/api/v1/ws/*` - WebSocket
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Все настройки в `app/core/config.py` через Pydantic Settings.
|
||||
Переменные окружения из `.env`.
|
||||
|
||||
## БД
|
||||
|
||||
- Миграции: `alembic upgrade head`
|
||||
- Async SQLAlchemy 2.0
|
||||
- UUID как primary keys
|
||||
|
||||
## Стиль кода
|
||||
|
||||
- Type hints везде
|
||||
- Async/await для всех I/O операций
|
||||
- Pydantic для валидации
|
||||
- FastAPI dependency injection
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## Деплой
|
||||
|
||||
Docker Compose для локальной разработки.
|
||||
Production: Docker + Nginx reverse proxy.
|
||||
|
||||
106
new-planet-backend/README.md
Normal file
106
new-planet-backend/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Новая Планета - Backend API
|
||||
|
||||
Backend API для мобильного приложения **Новая Планета** - визуальное расписание для детей с РАС.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Python** 3.11+
|
||||
- **FastAPI** 0.109+
|
||||
- **PostgreSQL** 15+ + **Redis** 7+
|
||||
- **SQLAlchemy** 2.0+ (async)
|
||||
- **GigaChat API** для ИИ-агента
|
||||
- **MinIO/S3** для хранения изображений
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Требования
|
||||
|
||||
- Python 3.11+
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
- Docker (опционально)
|
||||
|
||||
### Установка
|
||||
|
||||
1. Клонируйте репозиторий
|
||||
2. Установите зависимости:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Настройте `.env`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Отредактируйте .env с вашими настройками
|
||||
```
|
||||
|
||||
4. Запустите инфраструктуру (Docker):
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
5. Примените миграции:
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
6. Запустите сервер:
|
||||
```bash
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
API доступен на `http://localhost:8000`
|
||||
Swagger UI: `http://localhost:8000/docs`
|
||||
|
||||
## 📡 API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/v1/auth/register` - Регистрация
|
||||
- `POST /api/v1/auth/login` - Вход
|
||||
- `POST /api/v1/auth/refresh` - Обновление токена
|
||||
- `GET /api/v1/auth/me` - Текущий пользователь
|
||||
|
||||
### Schedules
|
||||
- `GET /api/v1/schedules` - Список расписаний
|
||||
- `POST /api/v1/schedules` - Создать расписание
|
||||
- `POST /api/v1/schedules/generate` - Сгенерировать через ИИ
|
||||
- `GET /api/v1/schedules/{id}` - Получить расписание
|
||||
- `PUT /api/v1/schedules/{id}` - Обновить
|
||||
- `DELETE /api/v1/schedules/{id}` - Удалить
|
||||
|
||||
### Tasks
|
||||
- `GET /api/v1/tasks/schedule/{schedule_id}` - Задачи расписания
|
||||
- `POST /api/v1/tasks` - Создать задачу
|
||||
- `PATCH /api/v1/tasks/{id}/complete` - Отметить выполненной
|
||||
|
||||
### AI
|
||||
- `POST /api/v1/ai/chat` - Чат с ИИ-агентом
|
||||
- `POST /api/v1/ai/schedule/generate` - Генерация расписания
|
||||
|
||||
### Images
|
||||
- `POST /api/v1/images/upload` - Загрузить изображение
|
||||
- `DELETE /api/v1/images/{key}` - Удалить изображение
|
||||
|
||||
### WebSocket
|
||||
- `WS /api/v1/ws/chat` - WebSocket чат с ИИ
|
||||
|
||||
## 🗄️ База данных
|
||||
|
||||
Используется PostgreSQL с async SQLAlchemy. Миграции через Alembic.
|
||||
|
||||
## 🤖 GigaChat Integration
|
||||
|
||||
Интеграция с GigaChat API для:
|
||||
- Чат с ИИ-агентом "Планета Земля"
|
||||
- Генерация расписаний
|
||||
|
||||
## 📚 Документация
|
||||
|
||||
- Swagger UI: `/docs`
|
||||
- ReDoc: `/redoc`
|
||||
|
||||
## 🔗 Связанные репозитории
|
||||
|
||||
- **Frontend (Android)** — [new-planet-android](https://github.com/your-org/new-planet-android)
|
||||
- **AI Agents** — [new-planet-ai-agents](https://github.com/your-org/new-planet-ai-agents)
|
||||
|
||||
115
new-planet-backend/alembic/alembic.ini
Normal file
115
new-planet-backend/alembic/alembic.ini
Normal file
@@ -0,0 +1,115 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
||||
72
new-planet-backend/alembic/env.py
Normal file
72
new-planet-backend/alembic/env.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from alembic import context
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
# this is the Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Import models and settings
|
||||
from app.db.base import Base
|
||||
from app.core.config import settings
|
||||
from app.models import user, schedule, task, reward, ai_conversation
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url():
|
||||
"""Получить URL БД"""
|
||||
return settings.database_url.replace("+asyncpg", "")
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = get_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
configuration = config.get_section(config.config_ini_section)
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
connectable = AsyncEngine(
|
||||
engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
future=True,
|
||||
)
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
|
||||
2
new-planet-backend/app/__init__.py
Normal file
2
new-planet-backend/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# App package
|
||||
|
||||
1
new-planet-backend/app/api/__init__.py
Normal file
1
new-planet-backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
44
new-planet-backend/app/api/deps.py
Normal file
44
new-planet-backend/app/api/deps.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
from app.crud import user as crud_user
|
||||
from app.services.auth_service import auth_service
|
||||
from app.models.user import User
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
"""Получить текущего пользователя из токена"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = auth_service.verify_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exception
|
||||
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
|
||||
user = await crud_user.get(db, user_id)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""Получить активного пользователя"""
|
||||
return current_user
|
||||
|
||||
4
new-planet-backend/app/api/v1/__init__.py
Normal file
4
new-planet-backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.api.v1.router import api_router
|
||||
|
||||
__all__ = ["api_router"]
|
||||
|
||||
60
new-planet-backend/app/api/v1/ai.py
Normal file
60
new-planet-backend/app/api/v1/ai.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.models.user import User
|
||||
from app.schemas.ai import ChatRequest, ChatResponse, ScheduleGenerateRequest, ScheduleGenerateResponse
|
||||
from app.services.chat_service import chat_service
|
||||
from app.services.schedule_generator import schedule_generator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/chat", response_model=ChatResponse)
|
||||
async def chat_with_ai(
|
||||
request: ChatRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Чат с ИИ-агентом 'Планета Земля'"""
|
||||
try:
|
||||
response = await chat_service.chat(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
request=request
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Chat error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/schedule/generate", response_model=ScheduleGenerateResponse)
|
||||
async def generate_schedule_ai(
|
||||
request: ScheduleGenerateRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Сгенерировать расписание через ИИ"""
|
||||
try:
|
||||
result = await schedule_generator.generate(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
child_age=request.child_age,
|
||||
preferences=request.preferences,
|
||||
schedule_date=request.date,
|
||||
description=request.description
|
||||
)
|
||||
return ScheduleGenerateResponse(
|
||||
schedule_id=result["schedule_id"],
|
||||
title=result["title"],
|
||||
tasks=result["tasks"]
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to generate schedule: {str(e)}"
|
||||
)
|
||||
|
||||
63
new-planet-backend/app/api/v1/auth.py
Normal file
63
new-planet-backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
from app.schemas.user import UserCreate, User
|
||||
from app.schemas.token import Token
|
||||
from app.services.auth_service import auth_service
|
||||
from app.api.deps import get_current_active_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=User, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_in: UserCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Регистрация нового пользователя"""
|
||||
try:
|
||||
user = await auth_service.register(db, user_in)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Аутентификация пользователя"""
|
||||
token = await auth_service.authenticate(db, form_data.username, form_data.password)
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
refresh_token: str = Body(..., embed=True)
|
||||
):
|
||||
"""Обновление access token"""
|
||||
new_access_token = auth_service.refresh_access_token(refresh_token)
|
||||
if not new_access_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
return Token(access_token=new_access_token, token_type="bearer")
|
||||
|
||||
|
||||
@router.get("/me", response_model=User)
|
||||
async def read_users_me(current_user: User = Depends(get_current_active_user)):
|
||||
"""Получить информацию о текущем пользователе"""
|
||||
return current_user
|
||||
|
||||
66
new-planet-backend/app/api/v1/images.py
Normal file
66
new-planet-backend/app/api/v1/images.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.models.user import User
|
||||
from app.services.storage_service import storage_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""Загрузить изображение"""
|
||||
# Проверка типа файла
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be an image"
|
||||
)
|
||||
|
||||
# Проверка размера (макс 10MB)
|
||||
file_content = await file.read()
|
||||
if len(file_content) > 10 * 1024 * 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File size must be less than 10MB"
|
||||
)
|
||||
|
||||
try:
|
||||
# Загружаем файл
|
||||
from io import BytesIO
|
||||
file_obj = BytesIO(file_content)
|
||||
url = await storage_service.upload_file(
|
||||
file_obj=file_obj,
|
||||
filename=file.filename or "image.jpg",
|
||||
content_type=file.content_type
|
||||
)
|
||||
return {"url": url, "filename": file.filename}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to upload image: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{file_key}")
|
||||
async def delete_image(
|
||||
file_key: str,
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""Удалить изображение"""
|
||||
try:
|
||||
success = await storage_service.delete_file(file_key)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
return {"message": "File deleted successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete image: {str(e)}"
|
||||
)
|
||||
|
||||
135
new-planet-backend/app/api/v1/rewards.py
Normal file
135
new-planet-backend/app/api/v1/rewards.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.models.user import User
|
||||
from app.schemas.reward import Reward, RewardCreate, RewardUpdate
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.reward import Reward as RewardModel
|
||||
|
||||
router = APIRouter()
|
||||
reward_crud = CRUDBase(RewardModel)
|
||||
|
||||
|
||||
@router.get("", response_model=List[Reward])
|
||||
async def get_rewards(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=100),
|
||||
is_claimed: bool = Query(None),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Получить список наград пользователя"""
|
||||
filters = {"user_id": current_user.id}
|
||||
if is_claimed is not None:
|
||||
filters["is_claimed"] = is_claimed
|
||||
|
||||
rewards = await reward_crud.get_multi(db, skip=skip, limit=limit, filters=filters)
|
||||
return rewards
|
||||
|
||||
|
||||
@router.get("/{reward_id}", response_model=Reward)
|
||||
async def get_reward(
|
||||
reward_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Получить награду по ID"""
|
||||
reward = await reward_crud.get(db, reward_id)
|
||||
if not reward:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Reward not found"
|
||||
)
|
||||
if reward.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
return reward
|
||||
|
||||
|
||||
@router.post("", response_model=Reward, status_code=status.HTTP_201_CREATED)
|
||||
async def create_reward(
|
||||
reward_in: RewardCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Создать новую награду"""
|
||||
reward_data = reward_in.model_dump()
|
||||
reward_data["user_id"] = current_user.id
|
||||
reward = await reward_crud.create(db, reward_data)
|
||||
return reward
|
||||
|
||||
|
||||
@router.put("/{reward_id}", response_model=Reward)
|
||||
async def update_reward(
|
||||
reward_id: str,
|
||||
reward_in: RewardUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Обновить награду"""
|
||||
reward = await reward_crud.get(db, reward_id)
|
||||
if not reward:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Reward not found"
|
||||
)
|
||||
if reward.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
update_data = reward_in.model_dump(exclude_unset=True)
|
||||
reward = await reward_crud.update(db, reward, update_data)
|
||||
return reward
|
||||
|
||||
|
||||
@router.delete("/{reward_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_reward(
|
||||
reward_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Удалить награду"""
|
||||
reward = await reward_crud.get(db, reward_id)
|
||||
if not reward:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Reward not found"
|
||||
)
|
||||
if reward.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
await reward_crud.delete(db, reward_id)
|
||||
return None
|
||||
|
||||
|
||||
@router.patch("/{reward_id}/claim", response_model=Reward)
|
||||
async def claim_reward(
|
||||
reward_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Получить награду (отметить как полученную)"""
|
||||
reward = await reward_crud.get(db, reward_id)
|
||||
if not reward:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Reward not found"
|
||||
)
|
||||
if reward.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
reward = await reward_crud.update(db, reward, {"is_claimed": True})
|
||||
return reward
|
||||
|
||||
13
new-planet-backend/app/api/v1/router.py
Normal file
13
new-planet-backend/app/api/v1/router.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, schedules, tasks, rewards, images, ai, websocket
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(schedules.router, prefix="/schedules", tags=["schedules"])
|
||||
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
|
||||
api_router.include_router(rewards.router, prefix="/rewards", tags=["rewards"])
|
||||
api_router.include_router(images.router, prefix="/images", tags=["images"])
|
||||
api_router.include_router(ai.router, prefix="/ai", tags=["ai"])
|
||||
api_router.include_router(websocket.router, prefix="/ws", tags=["websocket"])
|
||||
|
||||
141
new-planet-backend/app/api/v1/schedules.py
Normal file
141
new-planet-backend/app/api/v1/schedules.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import date
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.models.user import User
|
||||
from app.schemas.schedule import Schedule, ScheduleCreate, ScheduleUpdate
|
||||
from app.crud import schedule as crud_schedule
|
||||
from app.services.schedule_generator import schedule_generator
|
||||
from app.schemas.ai import ScheduleGenerateRequest, ScheduleGenerateResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=List[Schedule])
|
||||
async def get_schedules(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=100),
|
||||
schedule_date: date = Query(None),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Получить список расписаний пользователя"""
|
||||
if schedule_date:
|
||||
schedule = await crud_schedule.get_by_date(db, current_user.id, schedule_date)
|
||||
return [schedule] if schedule else []
|
||||
else:
|
||||
schedules = await crud_schedule.get_by_user(db, current_user.id, skip, limit)
|
||||
return schedules
|
||||
|
||||
|
||||
@router.get("/{schedule_id}", response_model=Schedule)
|
||||
async def get_schedule(
|
||||
schedule_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Получить расписание по ID"""
|
||||
schedule = await crud_schedule.get_with_tasks(db, schedule_id)
|
||||
if not schedule:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Schedule not found"
|
||||
)
|
||||
if schedule.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
return schedule
|
||||
|
||||
|
||||
@router.post("", response_model=Schedule, status_code=status.HTTP_201_CREATED)
|
||||
async def create_schedule(
|
||||
schedule_in: ScheduleCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Создать новое расписание"""
|
||||
schedule_data = schedule_in.model_dump()
|
||||
schedule_data["user_id"] = current_user.id
|
||||
schedule = await crud_schedule.create(db, schedule_data)
|
||||
return schedule
|
||||
|
||||
|
||||
@router.put("/{schedule_id}", response_model=Schedule)
|
||||
async def update_schedule(
|
||||
schedule_id: str,
|
||||
schedule_in: ScheduleUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Обновить расписание"""
|
||||
schedule = await crud_schedule.get(db, schedule_id)
|
||||
if not schedule:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Schedule not found"
|
||||
)
|
||||
if schedule.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
update_data = schedule_in.model_dump(exclude_unset=True)
|
||||
schedule = await crud_schedule.update(db, schedule, update_data)
|
||||
return schedule
|
||||
|
||||
|
||||
@router.delete("/{schedule_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_schedule(
|
||||
schedule_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Удалить расписание"""
|
||||
schedule = await crud_schedule.get(db, schedule_id)
|
||||
if not schedule:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Schedule not found"
|
||||
)
|
||||
if schedule.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
await crud_schedule.delete(db, schedule_id)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/generate", response_model=ScheduleGenerateResponse)
|
||||
async def generate_schedule(
|
||||
request: ScheduleGenerateRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Сгенерировать расписание через ИИ"""
|
||||
try:
|
||||
result = await schedule_generator.generate(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
child_age=request.child_age,
|
||||
preferences=request.preferences,
|
||||
schedule_date=request.date,
|
||||
description=request.description
|
||||
)
|
||||
return ScheduleGenerateResponse(
|
||||
schedule_id=result["schedule_id"],
|
||||
title=result["title"],
|
||||
tasks=result["tasks"]
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to generate schedule: {str(e)}"
|
||||
)
|
||||
|
||||
165
new-planet-backend/app/api/v1/tasks.py
Normal file
165
new-planet-backend/app/api/v1/tasks.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.models.user import User
|
||||
from app.schemas.task import Task, TaskCreate, TaskUpdate
|
||||
from app.crud import task as crud_task, schedule as crud_schedule
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/schedule/{schedule_id}", response_model=List[Task])
|
||||
async def get_tasks(
|
||||
schedule_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Получить все задачи расписания"""
|
||||
# Проверка прав доступа
|
||||
schedule = await crud_schedule.get(db, schedule_id)
|
||||
if not schedule:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Schedule not found"
|
||||
)
|
||||
if schedule.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
tasks = await crud_task.get_by_schedule(db, schedule_id)
|
||||
return tasks
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=Task)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Получить задачу по ID"""
|
||||
task = await crud_task.get(db, task_id)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Task not found"
|
||||
)
|
||||
|
||||
# Проверка прав доступа через расписание
|
||||
schedule = await crud_schedule.get(db, task.schedule_id)
|
||||
if schedule.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.post("", response_model=Task, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
task_in: TaskCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Создать новую задачу"""
|
||||
# Проверка прав доступа
|
||||
schedule = await crud_schedule.get(db, task_in.schedule_id)
|
||||
if not schedule:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Schedule not found"
|
||||
)
|
||||
if schedule.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
task = await crud_task.create(db, task_in.model_dump())
|
||||
return task
|
||||
|
||||
|
||||
@router.put("/{task_id}", response_model=Task)
|
||||
async def update_task(
|
||||
task_id: str,
|
||||
task_in: TaskUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Обновить задачу"""
|
||||
task = await crud_task.get(db, task_id)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Task not found"
|
||||
)
|
||||
|
||||
# Проверка прав доступа
|
||||
schedule = await crud_schedule.get(db, task.schedule_id)
|
||||
if schedule.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
update_data = task_in.model_dump(exclude_unset=True)
|
||||
task = await crud_task.update(db, task, update_data)
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/{task_id}/complete", response_model=Task)
|
||||
async def complete_task(
|
||||
task_id: str,
|
||||
completed: bool = True,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Отметить задачу как выполненную/невыполненную"""
|
||||
task = await crud_task.get(db, task_id)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Task not found"
|
||||
)
|
||||
|
||||
# Проверка прав доступа
|
||||
schedule = await crud_schedule.get(db, task.schedule_id)
|
||||
if schedule.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
task = await crud_task.update_completion(db, task_id, completed)
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_task(
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Удалить задачу"""
|
||||
task = await crud_task.get(db, task_id)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Task not found"
|
||||
)
|
||||
|
||||
# Проверка прав доступа
|
||||
schedule = await crud_schedule.get(db, task.schedule_id)
|
||||
if schedule.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
await crud_task.delete(db, task_id)
|
||||
return None
|
||||
|
||||
109
new-planet-backend/app/api/v1/websocket.py
Normal file
109
new-planet-backend/app/api/v1/websocket.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
|
||||
from typing import Dict, Set, Optional
|
||||
import json
|
||||
from app.services.chat_service import chat_service
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.security import decode_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Хранилище активных соединений
|
||||
active_connections: Dict[str, Set[WebSocket]] = {}
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: Dict[str, Set[WebSocket]] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, user_id: str):
|
||||
await websocket.accept()
|
||||
if user_id not in self.active_connections:
|
||||
self.active_connections[user_id] = set()
|
||||
self.active_connections[user_id].add(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket, user_id: str):
|
||||
if user_id in self.active_connections:
|
||||
self.active_connections[user_id].discard(websocket)
|
||||
if not self.active_connections[user_id]:
|
||||
del self.active_connections[user_id]
|
||||
|
||||
async def send_personal_message(self, message: str, websocket: WebSocket):
|
||||
await websocket.send_text(message)
|
||||
|
||||
async def broadcast_to_user(self, user_id: str, message: str):
|
||||
if user_id in self.active_connections:
|
||||
for connection in self.active_connections[user_id]:
|
||||
await connection.send_text(message)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
async def get_user_from_token(token: str) -> Optional[str]:
|
||||
"""Получить user_id из токена"""
|
||||
payload = decode_token(token)
|
||||
if payload:
|
||||
return payload.get("sub")
|
||||
return None
|
||||
|
||||
|
||||
@router.websocket("/ws/chat")
|
||||
async def websocket_chat(websocket: WebSocket):
|
||||
"""WebSocket endpoint для чата с ИИ"""
|
||||
# Получаем токен из query параметров
|
||||
token = websocket.query_params.get("token")
|
||||
if not token:
|
||||
await websocket.close(code=1008, reason="Token required")
|
||||
return
|
||||
|
||||
# Проверка токена
|
||||
user_id = await get_user_from_token(token)
|
||||
if not user_id:
|
||||
await websocket.close(code=1008, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await manager.connect(websocket, user_id)
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
message_data = json.loads(data)
|
||||
|
||||
# Создаем запрос для chat_service
|
||||
from app.schemas.ai import ChatRequest
|
||||
request = ChatRequest(
|
||||
message=message_data.get("message", ""),
|
||||
conversation_id=message_data.get("conversation_id")
|
||||
)
|
||||
|
||||
# Получаем ответ от ИИ
|
||||
try:
|
||||
response = await chat_service.chat(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Отправляем ответ клиенту
|
||||
await manager.send_personal_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"response": response.response,
|
||||
"conversation_id": response.conversation_id,
|
||||
"tokens_used": response.tokens_used
|
||||
}),
|
||||
websocket
|
||||
)
|
||||
except Exception as e:
|
||||
await manager.send_personal_message(
|
||||
json.dumps({
|
||||
"type": "error",
|
||||
"message": str(e)
|
||||
}),
|
||||
websocket
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket, user_id)
|
||||
|
||||
4
new-planet-backend/app/config.py
Normal file
4
new-planet-backend/app/config.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.core.config import settings
|
||||
|
||||
__all__ = ["settings"]
|
||||
|
||||
18
new-planet-backend/app/core/__init__.py
Normal file
18
new-planet-backend/app/core/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"settings",
|
||||
"verify_password",
|
||||
"get_password_hash",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"decode_token",
|
||||
]
|
||||
|
||||
73
new-planet-backend/app/core/config.py
Normal file
73
new-planet-backend/app/core/config.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# App
|
||||
PROJECT_NAME: str = "Новая Планета API"
|
||||
VERSION: str = "1.0.0"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
DEBUG: bool = False
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Database
|
||||
POSTGRES_USER: str
|
||||
POSTGRES_PASSWORD: str
|
||||
POSTGRES_DB: str
|
||||
POSTGRES_HOST: str = "localhost"
|
||||
POSTGRES_PORT: int = 5432
|
||||
DATABASE_URL: Optional[str] = None
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
if self.DATABASE_URL:
|
||||
return self.DATABASE_URL
|
||||
return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = "localhost"
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_DB: int = 0
|
||||
REDIS_URL: Optional[str] = None
|
||||
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
if self.REDIS_URL:
|
||||
return self.REDIS_URL
|
||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
|
||||
# Storage (MinIO/S3)
|
||||
STORAGE_ENDPOINT: str = "localhost:9000"
|
||||
STORAGE_ACCESS_KEY: str
|
||||
STORAGE_SECRET_KEY: str
|
||||
STORAGE_BUCKET: str = "new-planet-images"
|
||||
STORAGE_USE_SSL: bool = False
|
||||
STORAGE_REGION: str = "us-east-1"
|
||||
|
||||
# GigaChat
|
||||
GIGACHAT_CLIENT_ID: str
|
||||
GIGACHAT_CLIENT_SECRET: str
|
||||
GIGACHAT_AUTH_URL: str = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
|
||||
GIGACHAT_BASE_URL: str = "https://gigachat.devices.sberbank.ru/api/v1"
|
||||
GIGACHAT_MODEL_CHAT: str = "GigaChat-2-Lite"
|
||||
GIGACHAT_MODEL_SCHEDULE: str = "GigaChat-2-Pro"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["*"]
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED: bool = True
|
||||
RATE_LIMIT_PER_MINUTE: int = 60
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
36
new-planet-backend/app/core/logging.py
Normal file
36
new-planet-backend/app/core/logging.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import logging
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
def setup_logging(log_level: str = "INFO"):
|
||||
"""Настройка логирования"""
|
||||
log_dir = Path("logs")
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Формат логов
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
# Консольный handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
# Файловый handler
|
||||
file_handler = RotatingFileHandler(
|
||||
log_dir / "app.log",
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=5
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
# Root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
return root_logger
|
||||
|
||||
49
new-planet-backend/app/core/security.py
Normal file
49
new-planet-backend/app/core/security.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Проверка пароля"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Хеширование пароля"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Создание JWT access token"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: Dict[str, Any]) -> str:
|
||||
"""Создание JWT refresh token"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Декодирование JWT token"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
13
new-planet-backend/app/crud/__init__.py
Normal file
13
new-planet-backend/app/crud/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from app.crud.user import user, CRUDUser
|
||||
from app.crud.schedule import schedule, CRUDSchedule
|
||||
from app.crud.task import task, CRUDTask
|
||||
|
||||
__all__ = [
|
||||
"user",
|
||||
"CRUDUser",
|
||||
"schedule",
|
||||
"CRUDSchedule",
|
||||
"task",
|
||||
"CRUDTask",
|
||||
]
|
||||
|
||||
66
new-planet-backend/app/crud/base.py
Normal file
66
new-planet-backend/app/crud/base.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from typing import Generic, TypeVar, Type, Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.db.base import BaseModel
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=BaseModel)
|
||||
|
||||
|
||||
class CRUDBase(Generic[ModelType]):
|
||||
def __init__(self, model: Type[ModelType]):
|
||||
self.model = model
|
||||
|
||||
async def get(self, db: AsyncSession, id: str) -> Optional[ModelType]:
|
||||
"""Получить объект по ID"""
|
||||
result = await db.execute(select(self.model).where(self.model.id == id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_multi(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> List[ModelType]:
|
||||
"""Получить список объектов"""
|
||||
query = select(self.model)
|
||||
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
if hasattr(self.model, key):
|
||||
query = query.where(getattr(self.model, key) == value)
|
||||
|
||||
query = query.offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def create(self, db: AsyncSession, obj_in: Dict[str, Any]) -> ModelType:
|
||||
"""Создать объект"""
|
||||
db_obj = self.model(**obj_in)
|
||||
db.add(db_obj)
|
||||
await db.commit()
|
||||
await db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
async def update(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
db_obj: ModelType,
|
||||
obj_in: Dict[str, Any]
|
||||
) -> ModelType:
|
||||
"""Обновить объект"""
|
||||
for field, value in obj_in.items():
|
||||
if hasattr(db_obj, field) and value is not None:
|
||||
setattr(db_obj, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
async def delete(self, db: AsyncSession, id: str) -> bool:
|
||||
"""Удалить объект"""
|
||||
result = await db.execute(delete(self.model).where(self.model.id == id))
|
||||
await db.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
55
new-planet-backend/app/crud/schedule.py
Normal file
55
new-planet-backend/app/crud/schedule.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import date
|
||||
from app.models.schedule import Schedule
|
||||
from app.schemas.schedule import ScheduleCreate, ScheduleUpdate
|
||||
from app.crud.base import CRUDBase
|
||||
|
||||
|
||||
class CRUDSchedule(CRUDBase[Schedule]):
|
||||
async def get_by_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100
|
||||
) -> List[Schedule]:
|
||||
"""Получить расписания пользователя"""
|
||||
result = await db.execute(
|
||||
select(Schedule)
|
||||
.where(Schedule.user_id == user_id)
|
||||
.options(selectinload(Schedule.tasks))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.order_by(Schedule.date.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_by_date(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
schedule_date: date
|
||||
) -> Optional[Schedule]:
|
||||
"""Получить расписание на конкретную дату"""
|
||||
result = await db.execute(
|
||||
select(Schedule)
|
||||
.where(Schedule.user_id == user_id, Schedule.date == schedule_date)
|
||||
.options(selectinload(Schedule.tasks))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_with_tasks(self, db: AsyncSession, id: str) -> Optional[Schedule]:
|
||||
"""Получить расписание с задачами"""
|
||||
result = await db.execute(
|
||||
select(Schedule)
|
||||
.where(Schedule.id == id)
|
||||
.options(selectinload(Schedule.tasks))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
schedule = CRUDSchedule(Schedule)
|
||||
|
||||
39
new-planet-backend/app/crud/task.py
Normal file
39
new-planet-backend/app/crud/task.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.task import Task
|
||||
from app.schemas.task import TaskCreate, TaskUpdate
|
||||
from app.crud.base import CRUDBase
|
||||
|
||||
|
||||
class CRUDTask(CRUDBase[Task]):
|
||||
async def get_by_schedule(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
schedule_id: str
|
||||
) -> List[Task]:
|
||||
"""Получить все задачи расписания"""
|
||||
result = await db.execute(
|
||||
select(Task)
|
||||
.where(Task.schedule_id == schedule_id)
|
||||
.order_by(Task.order)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def update_completion(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
task_id: str,
|
||||
completed: bool
|
||||
) -> Optional[Task]:
|
||||
"""Обновить статус выполнения задачи"""
|
||||
task = await self.get(db, task_id)
|
||||
if task:
|
||||
task.completed = completed
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
task = CRUDTask(Task)
|
||||
|
||||
51
new-planet-backend/app/crud/user.py
Normal file
51
new-planet-backend/app/crud/user.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
from app.crud.base import CRUDBase
|
||||
|
||||
|
||||
class CRUDUser(CRUDBase[User]):
|
||||
async def get_by_email(self, db: AsyncSession, email: str) -> Optional[User]:
|
||||
"""Получить пользователя по email"""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, db: AsyncSession, obj_in: UserCreate, hashed_password: str) -> User:
|
||||
"""Создать пользователя"""
|
||||
db_obj = User(
|
||||
email=obj_in.email,
|
||||
hashed_password=hashed_password,
|
||||
role=obj_in.role,
|
||||
full_name=obj_in.full_name
|
||||
)
|
||||
db.add(db_obj)
|
||||
await db.commit()
|
||||
await db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
async def update(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
db_obj: User,
|
||||
obj_in: UserUpdate
|
||||
) -> User:
|
||||
"""Обновить пользователя"""
|
||||
update_data = obj_in.model_dump(exclude_unset=True)
|
||||
|
||||
if "password" in update_data:
|
||||
# Пароль нужно хешировать отдельно
|
||||
from app.core.security import get_password_hash
|
||||
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(db_obj, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
|
||||
user = CRUDUser(User)
|
||||
|
||||
16
new-planet-backend/app/db/__init__.py
Normal file
16
new-planet-backend/app/db/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from app.db.base import Base, BaseModel
|
||||
from app.db.session import get_db, AsyncSessionLocal
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from app.db.session import engine as _engine
|
||||
|
||||
# Для обратной совместимости
|
||||
engine: AsyncEngine = _engine
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"BaseModel",
|
||||
"get_db",
|
||||
"AsyncSessionLocal",
|
||||
"engine",
|
||||
]
|
||||
|
||||
30
new-planet-backend/app/db/base.py
Normal file
30
new-planet-backend/app/db/base.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class BaseModel(Base):
|
||||
"""Базовая модель с общими полями"""
|
||||
__abstract__ = True
|
||||
|
||||
id = Column(
|
||||
UUID(as_uuid=False),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid.uuid4()),
|
||||
nullable=False
|
||||
)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
10
new-planet-backend/app/db/init_db.py
Normal file
10
new-planet-backend/app/db/init_db.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
from app.models import user, schedule, task, reward, ai_conversation
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Инициализация БД - создание таблиц"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
26
new-planet-backend/app/db/session.py
Normal file
26
new-planet-backend/app/db/session.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency для получения сессии БД"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
80
new-planet-backend/app/main.py
Normal file
80
new-planet-backend/app/main.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from app.core.config import settings
|
||||
from app.core.logging import setup_logging
|
||||
from app.api.v1.router import api_router
|
||||
from app.middleware import (
|
||||
setup_cors,
|
||||
validation_exception_handler,
|
||||
http_exception_handler,
|
||||
general_exception_handler,
|
||||
RateLimitMiddleware,
|
||||
)
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# Настройка логирования
|
||||
logger = setup_logging(settings.DEBUG and "DEBUG" or "INFO")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Lifecycle events"""
|
||||
# Startup
|
||||
logger.info("Starting up...")
|
||||
from app.services.cache_service import cache_service
|
||||
await cache_service.connect()
|
||||
yield
|
||||
# Shutdown
|
||||
logger.info("Shutting down...")
|
||||
await cache_service.disconnect()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Middleware
|
||||
setup_cors(app)
|
||||
if settings.RATE_LIMIT_ENABLED:
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
|
||||
# Exception handlers
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
# Routers
|
||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": "Новая Планета API",
|
||||
"version": settings.VERSION,
|
||||
"docs": "/docs"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=settings.DEBUG
|
||||
)
|
||||
|
||||
18
new-planet-backend/app/middleware/__init__.py
Normal file
18
new-planet-backend/app/middleware/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from app.middleware.cors import setup_cors
|
||||
from app.middleware.error_handler import (
|
||||
validation_exception_handler,
|
||||
http_exception_handler,
|
||||
general_exception_handler,
|
||||
)
|
||||
from app.middleware.rate_limiter import RateLimitMiddleware
|
||||
from app.middleware.auth import AuthMiddleware
|
||||
|
||||
__all__ = [
|
||||
"setup_cors",
|
||||
"validation_exception_handler",
|
||||
"http_exception_handler",
|
||||
"general_exception_handler",
|
||||
"RateLimitMiddleware",
|
||||
"AuthMiddleware",
|
||||
]
|
||||
|
||||
49
new-planet-backend/app/middleware/auth.py
Normal file
49
new-planet-backend/app/middleware/auth.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from fastapi import Request, HTTPException, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.core.security import decode_token
|
||||
from app.api.deps import oauth2_scheme
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware для проверки аутентификации на защищенных маршрутах"""
|
||||
|
||||
# Пути, которые не требуют аутентификации
|
||||
PUBLIC_PATHS = [
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/register",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/redoc"
|
||||
]
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Пропускаем публичные пути
|
||||
if any(request.url.path.startswith(path) for path in self.PUBLIC_PATHS):
|
||||
return await call_next(request)
|
||||
|
||||
# Проверяем токен для защищенных путей
|
||||
authorization = request.headers.get("Authorization")
|
||||
if not authorization:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
token = authorization.replace("Bearer ", "")
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
15
new-planet-backend/app/middleware/cors.py
Normal file
15
new-planet-backend/app/middleware/cors.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import FastAPI
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def setup_cors(app: FastAPI):
|
||||
"""Настройка CORS"""
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
36
new-planet-backend/app/middleware/error_handler.py
Normal file
36
new-planet-backend/app/middleware/error_handler.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from fastapi import Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""Обработчик ошибок валидации"""
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"detail": exc.errors(),
|
||||
"body": exc.body
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||
"""Обработчик HTTP исключений"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail}
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""Обработчик общих исключений"""
|
||||
logger.error(f"Unhandled exception: {str(exc)}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "Internal server error"}
|
||||
)
|
||||
|
||||
35
new-planet-backend/app/middleware/rate_limiter.py
Normal file
35
new-planet-backend/app/middleware/rate_limiter.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi import Request, HTTPException, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.core.config import settings
|
||||
from app.services.cache_service import cache_service
|
||||
import time
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return await call_next(request)
|
||||
|
||||
# Получаем IP адрес клиента
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Формируем ключ для кэша
|
||||
cache_key = f"rate_limit:{client_ip}"
|
||||
|
||||
# Проверяем количество запросов
|
||||
current_requests = await cache_service.get(cache_key)
|
||||
|
||||
if current_requests:
|
||||
count = int(current_requests)
|
||||
if count >= settings.RATE_LIMIT_PER_MINUTE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded"
|
||||
)
|
||||
await cache_service.set(cache_key, str(count + 1), expire=60)
|
||||
else:
|
||||
await cache_service.set(cache_key, "1", expire=60)
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
15
new-planet-backend/app/models/__init__.py
Normal file
15
new-planet-backend/app/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.schedule import Schedule
|
||||
from app.models.task import Task
|
||||
from app.models.reward import Reward
|
||||
from app.models.ai_conversation import AIConversation
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"UserRole",
|
||||
"Schedule",
|
||||
"Task",
|
||||
"Reward",
|
||||
"AIConversation",
|
||||
]
|
||||
|
||||
19
new-planet-backend/app/models/ai_conversation.py
Normal file
19
new-planet-backend/app/models/ai_conversation.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, String, ForeignKey, Text, Integer, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.base import BaseModel
|
||||
|
||||
|
||||
class AIConversation(BaseModel):
|
||||
__tablename__ = "ai_conversations"
|
||||
|
||||
user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
conversation_id = Column(String, nullable=False, unique=True, index=True)
|
||||
message = Column(Text, nullable=False)
|
||||
response = Column(Text, nullable=False)
|
||||
tokens_used = Column(Integer, nullable=True)
|
||||
model = Column(String(100), nullable=True)
|
||||
context = Column(JSON, nullable=True) # Сохранение контекста для продолжения диалога
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="conversations")
|
||||
|
||||
18
new-planet-backend/app/models/reward.py
Normal file
18
new-planet-backend/app/models/reward.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import Column, String, Integer, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.base import BaseModel
|
||||
|
||||
|
||||
class Reward(BaseModel):
|
||||
__tablename__ = "rewards"
|
||||
|
||||
user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
image_url = Column(String(500), nullable=True)
|
||||
points_required = Column(Integer, nullable=False, default=1)
|
||||
is_claimed = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="rewards")
|
||||
|
||||
17
new-planet-backend/app/models/schedule.py
Normal file
17
new-planet-backend/app/models/schedule.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import Column, String, Date, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.base import BaseModel
|
||||
|
||||
|
||||
class Schedule(BaseModel):
|
||||
__tablename__ = "schedules"
|
||||
|
||||
user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
date = Column(Date, nullable=False, index=True)
|
||||
description = Column(String(1000), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="schedules")
|
||||
tasks = relationship("Task", back_populates="schedule", cascade="all, delete-orphan", order_by="Task.order")
|
||||
|
||||
20
new-planet-backend/app/models/task.py
Normal file
20
new-planet-backend/app/models/task.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import Column, String, Integer, Boolean, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.base import BaseModel
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
schedule_id = Column(String, ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
image_url = Column(String(500), nullable=True)
|
||||
duration_minutes = Column(Integer, nullable=False, default=30)
|
||||
completed = Column(Boolean, default=False, nullable=False)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
category = Column(String(100), nullable=True)
|
||||
|
||||
# Relationships
|
||||
schedule = relationship("Schedule", back_populates="tasks")
|
||||
|
||||
25
new-planet-backend/app/models/user.py
Normal file
25
new-planet-backend/app/models/user.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import Column, String, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
from app.db.base import BaseModel
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
CHILD = "CHILD"
|
||||
PARENT = "PARENT"
|
||||
EDUCATOR = "EDUCATOR"
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
__tablename__ = "users"
|
||||
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
role = Column(Enum(UserRole), nullable=False, default=UserRole.CHILD)
|
||||
full_name = Column(String(255), nullable=True)
|
||||
|
||||
# Relationships
|
||||
schedules = relationship("Schedule", back_populates="user", cascade="all, delete-orphan")
|
||||
rewards = relationship("Reward", back_populates="user", cascade="all, delete-orphan")
|
||||
conversations = relationship("AIConversation", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
36
new-planet-backend/app/schemas/__init__.py
Normal file
36
new-planet-backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from app.schemas.user import User, UserCreate, UserUpdate, UserInDB
|
||||
from app.schemas.token import Token, TokenData
|
||||
from app.schemas.schedule import Schedule, ScheduleCreate, ScheduleUpdate
|
||||
from app.schemas.task import Task, TaskCreate, TaskUpdate
|
||||
from app.schemas.reward import Reward, RewardCreate, RewardUpdate
|
||||
from app.schemas.ai import (
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
ScheduleGenerateRequest,
|
||||
ScheduleGenerateResponse,
|
||||
ConversationHistory,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserInDB",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"Schedule",
|
||||
"ScheduleCreate",
|
||||
"ScheduleUpdate",
|
||||
"Task",
|
||||
"TaskCreate",
|
||||
"TaskUpdate",
|
||||
"Reward",
|
||||
"RewardCreate",
|
||||
"RewardUpdate",
|
||||
"ChatRequest",
|
||||
"ChatResponse",
|
||||
"ScheduleGenerateRequest",
|
||||
"ScheduleGenerateResponse",
|
||||
"ConversationHistory",
|
||||
]
|
||||
|
||||
40
new-planet-backend/app/schemas/ai.py
Normal file
40
new-planet-backend/app/schemas/ai.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str = Field(..., min_length=1, max_length=2000)
|
||||
conversation_id: Optional[str] = None
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
response: str
|
||||
conversation_id: str
|
||||
tokens_used: Optional[int] = None
|
||||
model: Optional[str] = None
|
||||
|
||||
|
||||
class ScheduleGenerateRequest(BaseModel):
|
||||
child_age: int = Field(..., ge=1, le=18)
|
||||
preferences: List[str] = Field(default_factory=list)
|
||||
date: str # ISO format date string
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ScheduleGenerateResponse(BaseModel):
|
||||
schedule_id: str
|
||||
title: str
|
||||
tasks: List[Dict[str, Any]]
|
||||
tokens_used: Optional[int] = None
|
||||
|
||||
|
||||
class ConversationHistory(BaseModel):
|
||||
conversation_id: str
|
||||
messages: List[Dict[str, Any]]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
38
new-planet-backend/app/schemas/reward.py
Normal file
38
new-planet-backend/app/schemas/reward.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class RewardBase(BaseModel):
|
||||
title: str = Field(..., max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
points_required: int = Field(default=1, ge=1)
|
||||
|
||||
|
||||
class RewardCreate(RewardBase):
|
||||
pass
|
||||
|
||||
|
||||
class RewardUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
points_required: Optional[int] = Field(None, ge=1)
|
||||
is_claimed: Optional[bool] = None
|
||||
|
||||
|
||||
class RewardInDB(RewardBase):
|
||||
id: str
|
||||
user_id: str
|
||||
is_claimed: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class Reward(RewardInDB):
|
||||
pass
|
||||
|
||||
35
new-planet-backend/app/schemas/schedule.py
Normal file
35
new-planet-backend/app/schemas/schedule.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import date, datetime
|
||||
from app.schemas.task import Task
|
||||
|
||||
|
||||
class ScheduleBase(BaseModel):
|
||||
title: str = Field(..., max_length=255)
|
||||
date: date
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ScheduleCreate(ScheduleBase):
|
||||
pass
|
||||
|
||||
|
||||
class ScheduleUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=255)
|
||||
date: Optional[date] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ScheduleInDB(ScheduleBase):
|
||||
id: str
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class Schedule(ScheduleInDB):
|
||||
tasks: List[Task] = []
|
||||
|
||||
42
new-planet-backend/app/schemas/task.py
Normal file
42
new-planet-backend/app/schemas/task.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TaskBase(BaseModel):
|
||||
title: str = Field(..., max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
duration_minutes: int = Field(default=30, ge=1)
|
||||
order: int = Field(default=0, ge=0)
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class TaskCreate(TaskBase):
|
||||
schedule_id: str
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
duration_minutes: Optional[int] = Field(None, ge=1)
|
||||
completed: Optional[bool] = None
|
||||
order: Optional[int] = Field(None, ge=0)
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class TaskInDB(TaskBase):
|
||||
id: str
|
||||
schedule_id: str
|
||||
completed: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class Task(TaskInDB):
|
||||
pass
|
||||
|
||||
14
new-planet-backend/app/schemas/token.py
Normal file
14
new-planet-backend/app/schemas/token.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
refresh_token: Optional[str] = None
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
user_id: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
34
new-planet-backend/app/schemas/user.py
Normal file
34
new-planet-backend/app/schemas/user.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
full_name: Optional[str] = None
|
||||
role: UserRole = UserRole.CHILD
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
full_name: Optional[str] = None
|
||||
password: Optional[str] = Field(None, min_length=8)
|
||||
|
||||
|
||||
class UserInDB(UserBase):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class User(UserInDB):
|
||||
pass
|
||||
|
||||
22
new-planet-backend/app/services/__init__.py
Normal file
22
new-planet-backend/app/services/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from app.services.auth_service import auth_service, AuthService
|
||||
from app.services.cache_service import cache_service, CacheService
|
||||
from app.services.storage_service import storage_service, StorageService
|
||||
from app.services.gigachat_service import gigachat_service, GigaChatService
|
||||
from app.services.chat_service import chat_service, ChatService
|
||||
from app.services.schedule_generator import schedule_generator, ScheduleGenerator
|
||||
|
||||
__all__ = [
|
||||
"auth_service",
|
||||
"AuthService",
|
||||
"cache_service",
|
||||
"CacheService",
|
||||
"storage_service",
|
||||
"StorageService",
|
||||
"gigachat_service",
|
||||
"GigaChatService",
|
||||
"chat_service",
|
||||
"ChatService",
|
||||
"schedule_generator",
|
||||
"ScheduleGenerator",
|
||||
]
|
||||
|
||||
73
new-planet-backend/app/services/auth_service.py
Normal file
73
new-planet-backend/app/services/auth_service.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.crud import user as crud_user
|
||||
from app.core.security import verify_password, create_access_token, create_refresh_token, decode_token
|
||||
from app.schemas.user import UserCreate
|
||||
from app.schemas.token import Token
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class AuthService:
|
||||
async def authenticate(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
email: str,
|
||||
password: str
|
||||
) -> Optional[Token]:
|
||||
"""Аутентификация пользователя"""
|
||||
db_user = await crud_user.get_by_email(db, email)
|
||||
if not db_user:
|
||||
return None
|
||||
|
||||
if not verify_password(password, db_user.hashed_password):
|
||||
return None
|
||||
|
||||
access_token = create_access_token(
|
||||
data={"sub": db_user.id, "email": db_user.email}
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
data={"sub": db_user.id, "email": db_user.email}
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer"
|
||||
)
|
||||
|
||||
async def register(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_in: UserCreate
|
||||
):
|
||||
"""Регистрация нового пользователя"""
|
||||
# Проверка существования пользователя
|
||||
existing_user = await crud_user.get_by_email(db, user_in.email)
|
||||
if existing_user:
|
||||
raise ValueError("User with this email already exists")
|
||||
|
||||
from app.core.security import get_password_hash
|
||||
hashed_password = get_password_hash(user_in.password)
|
||||
|
||||
db_user = await crud_user.create(db, user_in, hashed_password)
|
||||
return db_user
|
||||
|
||||
def verify_token(self, token: str) -> Optional[dict]:
|
||||
"""Проверка токена"""
|
||||
payload = decode_token(token)
|
||||
if payload and payload.get("type") == "access":
|
||||
return payload
|
||||
return None
|
||||
|
||||
def refresh_access_token(self, refresh_token: str) -> Optional[str]:
|
||||
"""Обновление access token"""
|
||||
payload = decode_token(refresh_token)
|
||||
if payload and payload.get("type") == "refresh":
|
||||
return create_access_token(
|
||||
data={"sub": payload.get("sub"), "email": payload.get("email")}
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
auth_service = AuthService()
|
||||
|
||||
70
new-planet-backend/app/services/cache_service.py
Normal file
70
new-planet-backend/app/services/cache_service.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
from typing import Optional, List, Dict, Any
|
||||
import redis.asyncio as redis
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class CacheService:
|
||||
def __init__(self):
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Подключение к Redis"""
|
||||
self.redis_client = await redis.from_url(
|
||||
settings.redis_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
async def disconnect(self):
|
||||
"""Отключение от Redis"""
|
||||
if self.redis_client:
|
||||
await self.redis_client.close()
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""Получить значение по ключу"""
|
||||
if not self.redis_client:
|
||||
await self.connect()
|
||||
return await self.redis_client.get(key)
|
||||
|
||||
async def set(self, key: str, value: str, expire: int = 3600):
|
||||
"""Установить значение с TTL"""
|
||||
if not self.redis_client:
|
||||
await self.connect()
|
||||
await self.redis_client.setex(key, expire, value)
|
||||
|
||||
async def delete(self, key: str):
|
||||
"""Удалить ключ"""
|
||||
if not self.redis_client:
|
||||
await self.connect()
|
||||
await self.redis_client.delete(key)
|
||||
|
||||
async def get_conversation_context(
|
||||
self,
|
||||
conversation_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить контекст разговора"""
|
||||
key = f"conversation:{conversation_id}"
|
||||
data = await self.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return []
|
||||
|
||||
async def save_conversation_context(
|
||||
self,
|
||||
conversation_id: str,
|
||||
context: List[Dict[str, Any]],
|
||||
expire: int = 86400 * 7 # 7 дней
|
||||
):
|
||||
"""Сохранить контекст разговора"""
|
||||
key = f"conversation:{conversation_id}"
|
||||
await self.set(key, json.dumps(context), expire=expire)
|
||||
|
||||
async def cache_token(self, token: str, expire: int = 1800):
|
||||
"""Кэшировать токен (для rate limiting)"""
|
||||
key = f"token:{token}"
|
||||
await self.set(key, "1", expire=expire)
|
||||
|
||||
|
||||
cache_service = CacheService()
|
||||
|
||||
107
new-planet-backend/app/services/chat_service.py
Normal file
107
new-planet-backend/app/services/chat_service.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import uuid
|
||||
import json
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.services.gigachat_service import gigachat_service
|
||||
from app.services.cache_service import cache_service
|
||||
from app.models.ai_conversation import AIConversation
|
||||
from app.schemas.ai import ChatRequest, ChatResponse
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
# Персона "Планета Земля"
|
||||
EARTH_PERSONA = """Ты планета Земля - анимированный персонаж и друг детей с расстройством аутистического спектра (РАС).
|
||||
|
||||
Твоя личность:
|
||||
- Добрая, терпеливая, понимающая
|
||||
- Говоришь простым языком
|
||||
- Используешь эмодзи 🌍✨
|
||||
- Поощряешь любые достижения
|
||||
- Даешь четкие инструкции
|
||||
|
||||
Особенности общения:
|
||||
- Короткие предложения
|
||||
- Избегай сложных метафор
|
||||
- Подтверждай понимание
|
||||
- Задавай уточняющие вопросы
|
||||
- Будь позитивным и поддерживающим"""
|
||||
|
||||
|
||||
class ChatService:
|
||||
async def chat(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
request: ChatRequest
|
||||
) -> ChatResponse:
|
||||
"""Обработка чата с ИИ-агентом"""
|
||||
# Получить или создать conversation_id
|
||||
conversation_id = request.conversation_id or str(uuid.uuid4())
|
||||
|
||||
# Загрузить контекст из кэша
|
||||
context = await cache_service.get_conversation_context(conversation_id)
|
||||
|
||||
# Добавить персону в начало, если контекст пустой
|
||||
if not context:
|
||||
context.append({
|
||||
"role": "system",
|
||||
"content": EARTH_PERSONA
|
||||
})
|
||||
|
||||
# Добавить сообщение пользователя
|
||||
context.append({
|
||||
"role": "user",
|
||||
"content": request.message
|
||||
})
|
||||
|
||||
# Отправить запрос в GigaChat
|
||||
try:
|
||||
result = await gigachat_service.chat(
|
||||
message=request.message,
|
||||
context=context[:-1], # Без последнего сообщения (оно добавится автоматически)
|
||||
model=settings.GIGACHAT_MODEL_CHAT
|
||||
)
|
||||
|
||||
# Извлечь ответ
|
||||
choices = result.get("choices", [])
|
||||
if not choices:
|
||||
raise Exception("No response from GigaChat")
|
||||
|
||||
response_text = choices[0].get("message", {}).get("content", "")
|
||||
tokens_used = result.get("usage", {}).get("total_tokens")
|
||||
model_used = result.get("model")
|
||||
|
||||
# Добавить ответ в контекст
|
||||
context.append({
|
||||
"role": "assistant",
|
||||
"content": response_text
|
||||
})
|
||||
|
||||
# Сохранить контекст в кэш
|
||||
await cache_service.save_conversation_context(conversation_id, context)
|
||||
|
||||
# Сохранить в БД
|
||||
conversation = AIConversation(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
message=request.message,
|
||||
response=response_text,
|
||||
tokens_used=tokens_used,
|
||||
model=model_used,
|
||||
context=context
|
||||
)
|
||||
db.add(conversation)
|
||||
await db.commit()
|
||||
|
||||
return ChatResponse(
|
||||
response=response_text,
|
||||
conversation_id=conversation_id,
|
||||
tokens_used=tokens_used,
|
||||
model=model_used
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(f"Chat service error: {str(e)}")
|
||||
|
||||
|
||||
chat_service = ChatService()
|
||||
|
||||
102
new-planet-backend/app/services/gigachat_service.py
Normal file
102
new-planet-backend/app/services/gigachat_service.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import aiohttp
|
||||
import base64
|
||||
import uuid
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class GigaChatService:
|
||||
def __init__(self):
|
||||
self.access_token: Optional[str] = None
|
||||
self.token_expires_at: Optional[float] = None
|
||||
|
||||
async def _get_token(self) -> str:
|
||||
"""Получить OAuth токен"""
|
||||
# Проверяем, не истек ли токен (оставляем запас 60 секунд)
|
||||
if self.access_token and self.token_expires_at:
|
||||
if time.time() < (self.token_expires_at - 60):
|
||||
return self.access_token
|
||||
|
||||
credentials = f"{settings.GIGACHAT_CLIENT_ID}:{settings.GIGACHAT_CLIENT_SECRET}"
|
||||
encoded_credentials = base64.b64encode(credentials.encode()).decode()
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Basic {encoded_credentials}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
"RqUID": str(uuid.uuid4())
|
||||
}
|
||||
|
||||
data = {"scope": "GIGACHAT_API_PERS"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
settings.GIGACHAT_AUTH_URL,
|
||||
headers=headers,
|
||||
data=data
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise Exception(f"Failed to get token: {response.status}")
|
||||
|
||||
result = await response.json()
|
||||
self.access_token = result.get("access_token")
|
||||
expires_in = result.get("expires_at", 1800)
|
||||
# expires_at может быть timestamp или количество секунд
|
||||
if expires_in > 1000000000: # Это timestamp
|
||||
self.token_expires_at = expires_in
|
||||
else: # Это количество секунд
|
||||
self.token_expires_at = time.time() + expires_in
|
||||
|
||||
return self.access_token
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
message: str,
|
||||
context: Optional[List[Dict[str, Any]]] = None,
|
||||
model: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Отправить сообщение в GigaChat"""
|
||||
token = await self._get_token()
|
||||
model = model or settings.GIGACHAT_MODEL_CHAT
|
||||
|
||||
messages = context or []
|
||||
messages.append({"role": "user", "content": message})
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 2000
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{settings.GIGACHAT_BASE_URL}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise Exception(f"GigaChat API error: {response.status} - {error_text}")
|
||||
|
||||
result = await response.json()
|
||||
return result
|
||||
|
||||
async def generate_text(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str = None
|
||||
) -> str:
|
||||
"""Генерация текста по промпту"""
|
||||
result = await self.chat(prompt, model=model)
|
||||
return result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
|
||||
gigachat_service = GigaChatService()
|
||||
|
||||
130
new-planet-backend/app/services/schedule_generator.py
Normal file
130
new-planet-backend/app/services/schedule_generator.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.services.gigachat_service import gigachat_service
|
||||
from app.core.config import settings
|
||||
from app.crud import schedule as crud_schedule, task as crud_task
|
||||
from app.schemas.schedule import ScheduleCreate
|
||||
from app.schemas.task import TaskCreate
|
||||
from datetime import date
|
||||
|
||||
|
||||
SCHEDULE_GENERATION_PROMPT = """Ты планета Земля, друг детей с расстройством аутистического спектра (РАС).
|
||||
|
||||
Создай расписание на {date} для ребенка {age} лет.
|
||||
Предпочтения ребенка: {preferences}
|
||||
|
||||
Важные правила:
|
||||
1. Задания должны быть простыми и понятными
|
||||
2. Каждое задание имеет четкие временные рамки
|
||||
3. Используй визуальные описания
|
||||
4. Избегай резких переходов между активностями
|
||||
5. Включи время на отдых
|
||||
6. Учитывай особенности РАС
|
||||
|
||||
Верни ТОЛЬКО валидный JSON формат без дополнительного текста:
|
||||
{{
|
||||
"title": "Название расписания",
|
||||
"description": "Краткое описание",
|
||||
"tasks": [
|
||||
{{
|
||||
"title": "Название задания",
|
||||
"description": "Подробное описание",
|
||||
"duration_minutes": 30,
|
||||
"category": "утренняя_рутина",
|
||||
"order": 0
|
||||
}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
|
||||
class ScheduleGenerator:
|
||||
async def generate(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
child_age: int,
|
||||
preferences: List[str],
|
||||
schedule_date: str,
|
||||
description: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Генерация расписания через GigaChat"""
|
||||
# Формируем промпт
|
||||
prompt = SCHEDULE_GENERATION_PROMPT.format(
|
||||
date=schedule_date,
|
||||
age=child_age,
|
||||
preferences=", ".join(preferences) if preferences else "нет особых предпочтений"
|
||||
)
|
||||
|
||||
if description:
|
||||
prompt += f"\n\nДополнительная информация: {description}"
|
||||
|
||||
# Генерируем через GigaChat
|
||||
try:
|
||||
response_text = await gigachat_service.generate_text(
|
||||
prompt=prompt,
|
||||
model=settings.GIGACHAT_MODEL_SCHEDULE
|
||||
)
|
||||
|
||||
# Парсим JSON ответ
|
||||
# Убираем markdown код блоки если есть
|
||||
if "```json" in response_text:
|
||||
response_text = response_text.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in response_text:
|
||||
response_text = response_text.split("```")[1].split("```")[0].strip()
|
||||
|
||||
schedule_data = json.loads(response_text)
|
||||
|
||||
# Создаем расписание в БД
|
||||
schedule_create = ScheduleCreate(
|
||||
title=schedule_data.get("title", f"Расписание на {schedule_date}"),
|
||||
date=date.fromisoformat(schedule_date),
|
||||
description=schedule_data.get("description") or description
|
||||
)
|
||||
|
||||
db_schedule = await crud_schedule.create(
|
||||
db,
|
||||
{
|
||||
**schedule_create.model_dump(),
|
||||
"user_id": user_id
|
||||
}
|
||||
)
|
||||
|
||||
# Создаем задачи
|
||||
tasks_data = schedule_data.get("tasks", [])
|
||||
for task_data in tasks_data:
|
||||
task_create = TaskCreate(
|
||||
schedule_id=db_schedule.id,
|
||||
title=task_data.get("title"),
|
||||
description=task_data.get("description"),
|
||||
duration_minutes=task_data.get("duration_minutes", 30),
|
||||
category=task_data.get("category"),
|
||||
order=task_data.get("order", 0)
|
||||
)
|
||||
|
||||
await crud_task.create(db, task_create.model_dump())
|
||||
|
||||
await db.refresh(db_schedule)
|
||||
|
||||
return {
|
||||
"schedule_id": db_schedule.id,
|
||||
"title": db_schedule.title,
|
||||
"tasks": [
|
||||
{
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"duration_minutes": task.duration_minutes,
|
||||
"category": task.category,
|
||||
"order": task.order
|
||||
}
|
||||
for task in db_schedule.tasks
|
||||
]
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
raise Exception(f"Failed to parse GigaChat response as JSON: {str(e)}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Schedule generation error: {str(e)}")
|
||||
|
||||
|
||||
schedule_generator = ScheduleGenerator()
|
||||
|
||||
80
new-planet-backend/app/services/storage_service.py
Normal file
80
new-planet-backend/app/services/storage_service.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import uuid
|
||||
from typing import Optional, BinaryIO
|
||||
from botocore.exceptions import ClientError
|
||||
import boto3
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class StorageService:
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self._initialize_client()
|
||||
|
||||
def _initialize_client(self):
|
||||
"""Инициализация S3/MinIO клиента"""
|
||||
self.client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"{'https' if settings.STORAGE_USE_SSL else 'http'}://{settings.STORAGE_ENDPOINT}",
|
||||
aws_access_key_id=settings.STORAGE_ACCESS_KEY,
|
||||
aws_secret_access_key=settings.STORAGE_SECRET_KEY,
|
||||
region_name=settings.STORAGE_REGION,
|
||||
use_ssl=settings.STORAGE_USE_SSL,
|
||||
verify=False # Для MinIO в dev
|
||||
)
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
file_obj: BinaryIO,
|
||||
filename: str,
|
||||
content_type: str = "image/jpeg"
|
||||
) -> str:
|
||||
"""Загрузить файл в хранилище"""
|
||||
file_key = f"{uuid.uuid4()}_{filename}"
|
||||
|
||||
try:
|
||||
self.client.upload_fileobj(
|
||||
file_obj,
|
||||
settings.STORAGE_BUCKET,
|
||||
file_key,
|
||||
ExtraArgs={"ContentType": content_type}
|
||||
)
|
||||
|
||||
# Формируем URL
|
||||
url = f"{'https' if settings.STORAGE_USE_SSL else 'http'}://{settings.STORAGE_ENDPOINT}/{settings.STORAGE_BUCKET}/{file_key}"
|
||||
return url
|
||||
except ClientError as e:
|
||||
raise Exception(f"Failed to upload file: {str(e)}")
|
||||
|
||||
async def delete_file(self, file_key: str) -> bool:
|
||||
"""Удалить файл из хранилища"""
|
||||
try:
|
||||
# Извлекаем ключ из URL если передан полный URL
|
||||
if "/" in file_key:
|
||||
file_key = file_key.split("/")[-1]
|
||||
|
||||
self.client.delete_object(
|
||||
Bucket=settings.STORAGE_BUCKET,
|
||||
Key=file_key
|
||||
)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
async def get_file_url(self, file_key: str) -> Optional[str]:
|
||||
"""Получить URL файла"""
|
||||
try:
|
||||
if "/" in file_key:
|
||||
file_key = file_key.split("/")[-1]
|
||||
|
||||
url = self.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": settings.STORAGE_BUCKET, "Key": file_key},
|
||||
ExpiresIn=3600
|
||||
)
|
||||
return url
|
||||
except ClientError:
|
||||
return None
|
||||
|
||||
|
||||
storage_service = StorageService()
|
||||
|
||||
12
new-planet-backend/app/utils/__init__.py
Normal file
12
new-planet-backend/app/utils/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from app.utils.validators import validate_email, validate_password
|
||||
from app.utils.helpers import format_datetime, format_date, dict_to_json, json_to_dict
|
||||
|
||||
__all__ = [
|
||||
"validate_email",
|
||||
"validate_password",
|
||||
"format_datetime",
|
||||
"format_date",
|
||||
"dict_to_json",
|
||||
"json_to_dict",
|
||||
]
|
||||
|
||||
24
new-planet-backend/app/utils/helpers.py
Normal file
24
new-planet-backend/app/utils/helpers.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from typing import Any, Dict
|
||||
from datetime import datetime, date
|
||||
import json
|
||||
|
||||
|
||||
def format_datetime(dt: datetime) -> str:
|
||||
"""Форматирование datetime в ISO строку"""
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
def format_date(d: date) -> str:
|
||||
"""Форматирование date в ISO строку"""
|
||||
return d.isoformat()
|
||||
|
||||
|
||||
def dict_to_json(data: Dict[str, Any]) -> str:
|
||||
"""Преобразование словаря в JSON строку"""
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def json_to_dict(json_str: str) -> Dict[str, Any]:
|
||||
"""Преобразование JSON строки в словарь"""
|
||||
return json.loads(json_str)
|
||||
|
||||
27
new-planet-backend/app/utils/validators.py
Normal file
27
new-planet-backend/app/utils/validators.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from typing import Optional
|
||||
import re
|
||||
from pydantic import EmailStr, validator
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
"""Валидация email"""
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return bool(re.match(pattern, email))
|
||||
|
||||
|
||||
def validate_password(password: str) -> tuple[bool, Optional[str]]:
|
||||
"""Валидация пароля"""
|
||||
if len(password) < 8:
|
||||
return False, "Password must be at least 8 characters long"
|
||||
|
||||
if not re.search(r'[A-Z]', password):
|
||||
return False, "Password must contain at least one uppercase letter"
|
||||
|
||||
if not re.search(r'[a-z]', password):
|
||||
return False, "Password must contain at least one lowercase letter"
|
||||
|
||||
if not re.search(r'\d', password):
|
||||
return False, "Password must contain at least one digit"
|
||||
|
||||
return True, None
|
||||
|
||||
29
new-planet-backend/docker/Dockerfile
Normal file
29
new-planet-backend/docker/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Установка системных зависимостей
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копирование requirements
|
||||
COPY requirements.txt .
|
||||
|
||||
# Установка Python зависимостей
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Копирование кода приложения
|
||||
COPY . .
|
||||
|
||||
# Переменные окружения
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Expose порт
|
||||
EXPOSE 8000
|
||||
|
||||
# Команда запуска
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
56
new-planet-backend/docker/docker-compose.yml
Normal file
56
new-planet-backend/docker/docker-compose.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: newplanet-postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: newplanet
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: newplanet-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: newplanet-minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
|
||||
0
new-planet-backend/pyproject.toml
Normal file
0
new-planet-backend/pyproject.toml
Normal file
14
new-planet-backend/requirements.txt
Normal file
14
new-planet-backend/requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
fastapi>=0.109
|
||||
uvicorn[standard]
|
||||
sqlalchemy>=2.0
|
||||
alembic
|
||||
asyncpg
|
||||
redis
|
||||
pydantic
|
||||
pydantic-settings
|
||||
python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
boto3
|
||||
aiohttp
|
||||
websockets
|
||||
python-multipart
|
||||
0
new-planet-backend/tests/__init__.py
Normal file
0
new-planet-backend/tests/__init__.py
Normal file
0
new-planet-backend/tests/conftest.py
Normal file
0
new-planet-backend/tests/conftest.py
Normal file
0
new-planet-backend/tests/integration/__init__.py
Normal file
0
new-planet-backend/tests/integration/__init__.py
Normal file
0
new-planet-backend/tests/unit/__init__.py
Normal file
0
new-planet-backend/tests/unit/__init__.py
Normal file
Reference in New Issue
Block a user