This commit is contained in:
2025-12-13 14:39:50 +03:00
commit b666cdcb95
79 changed files with 3081 additions and 0 deletions

7
new-planet-backend/.env Normal file
View 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
View File

8
new-planet-backend/.idea/.gitignore generated vendored Normal file
View 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

View 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
View 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
View 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>

View 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>

View 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.

View 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)

View 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

View 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())

View File

@@ -0,0 +1,2 @@
# App package

View File

@@ -0,0 +1 @@

View 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

View File

@@ -0,0 +1,4 @@
from app.api.v1.router import api_router
__all__ = ["api_router"]

View 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)}"
)

View 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

View 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)}"
)

View 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

View 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"])

View 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)}"
)

View 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

View 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)

View File

@@ -0,0 +1,4 @@
from app.core.config import settings
__all__ = ["settings"]

View 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",
]

View 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()

View 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

View 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

View 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",
]

View 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

View 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)

View 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)

View 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)

View 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",
]

View 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
)

View 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)

View 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()

View 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
)

View 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",
]

View 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

View 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=["*"],
)

View 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"}
)

View 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

View 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",
]

View 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")

View 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")

View 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")

View 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")

View 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")

View 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",
]

View 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

View 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

View 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] = []

View 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

View 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

View 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

View 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",
]

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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",
]

View 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)

View 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

View 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"]

View 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:

View File

View 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

View File

View File