commit b666cdcb95fef0e7d156744f2ea77b18981f51d6 Author: gleb Date: Sat Dec 13 14:39:50 2025 +0300 init diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..31e1ebc --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..0f2bea1 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/new-planet-backend.iml b/.idea/new-planet-backend.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/new-planet-backend.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/new-planet-backend/.env b/new-planet-backend/.env new file mode 100644 index 0000000..103e017 --- /dev/null +++ b/new-planet-backend/.env @@ -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 diff --git a/new-planet-backend/.gitignore b/new-planet-backend/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/new-planet-backend/.idea/.gitignore b/new-planet-backend/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/new-planet-backend/.idea/.gitignore @@ -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 diff --git a/new-planet-backend/.idea/inspectionProfiles/profiles_settings.xml b/new-planet-backend/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/new-planet-backend/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/new-planet-backend/.idea/misc.xml b/new-planet-backend/.idea/misc.xml new file mode 100644 index 0000000..a8ee851 --- /dev/null +++ b/new-planet-backend/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/new-planet-backend/.idea/modules.xml b/new-planet-backend/.idea/modules.xml new file mode 100644 index 0000000..0f2bea1 --- /dev/null +++ b/new-planet-backend/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/new-planet-backend/.idea/new-planet-backend.iml b/new-planet-backend/.idea/new-planet-backend.iml new file mode 100644 index 0000000..d30b034 --- /dev/null +++ b/new-planet-backend/.idea/new-planet-backend.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/new-planet-backend/CLAUDE.md b/new-planet-backend/CLAUDE.md new file mode 100644 index 0000000..c0f7f26 --- /dev/null +++ b/new-planet-backend/CLAUDE.md @@ -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. + diff --git a/new-planet-backend/README.md b/new-planet-backend/README.md new file mode 100644 index 0000000..c912ba2 --- /dev/null +++ b/new-planet-backend/README.md @@ -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) + diff --git a/new-planet-backend/alembic/alembic.ini b/new-planet-backend/alembic/alembic.ini new file mode 100644 index 0000000..c1fdca1 --- /dev/null +++ b/new-planet-backend/alembic/alembic.ini @@ -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 + diff --git a/new-planet-backend/alembic/env.py b/new-planet-backend/alembic/env.py new file mode 100644 index 0000000..1c7702a --- /dev/null +++ b/new-planet-backend/alembic/env.py @@ -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()) + diff --git a/new-planet-backend/app/__init__.py b/new-planet-backend/app/__init__.py new file mode 100644 index 0000000..c11553b --- /dev/null +++ b/new-planet-backend/app/__init__.py @@ -0,0 +1,2 @@ +# App package + diff --git a/new-planet-backend/app/api/__init__.py b/new-planet-backend/app/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/new-planet-backend/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/new-planet-backend/app/api/deps.py b/new-planet-backend/app/api/deps.py new file mode 100644 index 0000000..bd73c8d --- /dev/null +++ b/new-planet-backend/app/api/deps.py @@ -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 + diff --git a/new-planet-backend/app/api/v1/__init__.py b/new-planet-backend/app/api/v1/__init__.py new file mode 100644 index 0000000..71a884e --- /dev/null +++ b/new-planet-backend/app/api/v1/__init__.py @@ -0,0 +1,4 @@ +from app.api.v1.router import api_router + +__all__ = ["api_router"] + diff --git a/new-planet-backend/app/api/v1/ai.py b/new-planet-backend/app/api/v1/ai.py new file mode 100644 index 0000000..4025fe6 --- /dev/null +++ b/new-planet-backend/app/api/v1/ai.py @@ -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)}" + ) + diff --git a/new-planet-backend/app/api/v1/auth.py b/new-planet-backend/app/api/v1/auth.py new file mode 100644 index 0000000..42b51e3 --- /dev/null +++ b/new-planet-backend/app/api/v1/auth.py @@ -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 + diff --git a/new-planet-backend/app/api/v1/images.py b/new-planet-backend/app/api/v1/images.py new file mode 100644 index 0000000..bdb94c5 --- /dev/null +++ b/new-planet-backend/app/api/v1/images.py @@ -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)}" + ) + diff --git a/new-planet-backend/app/api/v1/rewards.py b/new-planet-backend/app/api/v1/rewards.py new file mode 100644 index 0000000..790370c --- /dev/null +++ b/new-planet-backend/app/api/v1/rewards.py @@ -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 + diff --git a/new-planet-backend/app/api/v1/router.py b/new-planet-backend/app/api/v1/router.py new file mode 100644 index 0000000..9a547ab --- /dev/null +++ b/new-planet-backend/app/api/v1/router.py @@ -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"]) + diff --git a/new-planet-backend/app/api/v1/schedules.py b/new-planet-backend/app/api/v1/schedules.py new file mode 100644 index 0000000..b5f97e8 --- /dev/null +++ b/new-planet-backend/app/api/v1/schedules.py @@ -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)}" + ) + diff --git a/new-planet-backend/app/api/v1/tasks.py b/new-planet-backend/app/api/v1/tasks.py new file mode 100644 index 0000000..d8b8360 --- /dev/null +++ b/new-planet-backend/app/api/v1/tasks.py @@ -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 + diff --git a/new-planet-backend/app/api/v1/websocket.py b/new-planet-backend/app/api/v1/websocket.py new file mode 100644 index 0000000..fc8bb87 --- /dev/null +++ b/new-planet-backend/app/api/v1/websocket.py @@ -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) + diff --git a/new-planet-backend/app/config.py b/new-planet-backend/app/config.py new file mode 100644 index 0000000..fecef1b --- /dev/null +++ b/new-planet-backend/app/config.py @@ -0,0 +1,4 @@ +from app.core.config import settings + +__all__ = ["settings"] + diff --git a/new-planet-backend/app/core/__init__.py b/new-planet-backend/app/core/__init__.py new file mode 100644 index 0000000..617d924 --- /dev/null +++ b/new-planet-backend/app/core/__init__.py @@ -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", +] + diff --git a/new-planet-backend/app/core/config.py b/new-planet-backend/app/core/config.py new file mode 100644 index 0000000..4a71bd6 --- /dev/null +++ b/new-planet-backend/app/core/config.py @@ -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() + diff --git a/new-planet-backend/app/core/logging.py b/new-planet-backend/app/core/logging.py new file mode 100644 index 0000000..224164b --- /dev/null +++ b/new-planet-backend/app/core/logging.py @@ -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 + diff --git a/new-planet-backend/app/core/security.py b/new-planet-backend/app/core/security.py new file mode 100644 index 0000000..e8e25cf --- /dev/null +++ b/new-planet-backend/app/core/security.py @@ -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 + diff --git a/new-planet-backend/app/crud/__init__.py b/new-planet-backend/app/crud/__init__.py new file mode 100644 index 0000000..4945ec4 --- /dev/null +++ b/new-planet-backend/app/crud/__init__.py @@ -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", +] + diff --git a/new-planet-backend/app/crud/base.py b/new-planet-backend/app/crud/base.py new file mode 100644 index 0000000..82c191e --- /dev/null +++ b/new-planet-backend/app/crud/base.py @@ -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 + diff --git a/new-planet-backend/app/crud/schedule.py b/new-planet-backend/app/crud/schedule.py new file mode 100644 index 0000000..5d7398d --- /dev/null +++ b/new-planet-backend/app/crud/schedule.py @@ -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) + diff --git a/new-planet-backend/app/crud/task.py b/new-planet-backend/app/crud/task.py new file mode 100644 index 0000000..b8b57ed --- /dev/null +++ b/new-planet-backend/app/crud/task.py @@ -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) + diff --git a/new-planet-backend/app/crud/user.py b/new-planet-backend/app/crud/user.py new file mode 100644 index 0000000..28b8bc5 --- /dev/null +++ b/new-planet-backend/app/crud/user.py @@ -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) + diff --git a/new-planet-backend/app/db/__init__.py b/new-planet-backend/app/db/__init__.py new file mode 100644 index 0000000..b7a07ad --- /dev/null +++ b/new-planet-backend/app/db/__init__.py @@ -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", +] + diff --git a/new-planet-backend/app/db/base.py b/new-planet-backend/app/db/base.py new file mode 100644 index 0000000..c0d1286 --- /dev/null +++ b/new-planet-backend/app/db/base.py @@ -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 + ) + diff --git a/new-planet-backend/app/db/init_db.py b/new-planet-backend/app/db/init_db.py new file mode 100644 index 0000000..b973e2c --- /dev/null +++ b/new-planet-backend/app/db/init_db.py @@ -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) + diff --git a/new-planet-backend/app/db/session.py b/new-planet-backend/app/db/session.py new file mode 100644 index 0000000..04c4b72 --- /dev/null +++ b/new-planet-backend/app/db/session.py @@ -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() + diff --git a/new-planet-backend/app/main.py b/new-planet-backend/app/main.py new file mode 100644 index 0000000..80e524f --- /dev/null +++ b/new-planet-backend/app/main.py @@ -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 + ) + diff --git a/new-planet-backend/app/middleware/__init__.py b/new-planet-backend/app/middleware/__init__.py new file mode 100644 index 0000000..ea657db --- /dev/null +++ b/new-planet-backend/app/middleware/__init__.py @@ -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", +] + diff --git a/new-planet-backend/app/middleware/auth.py b/new-planet-backend/app/middleware/auth.py new file mode 100644 index 0000000..2605835 --- /dev/null +++ b/new-planet-backend/app/middleware/auth.py @@ -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 + diff --git a/new-planet-backend/app/middleware/cors.py b/new-planet-backend/app/middleware/cors.py new file mode 100644 index 0000000..e3a4dc0 --- /dev/null +++ b/new-planet-backend/app/middleware/cors.py @@ -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=["*"], + ) + diff --git a/new-planet-backend/app/middleware/error_handler.py b/new-planet-backend/app/middleware/error_handler.py new file mode 100644 index 0000000..cca8238 --- /dev/null +++ b/new-planet-backend/app/middleware/error_handler.py @@ -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"} + ) + diff --git a/new-planet-backend/app/middleware/rate_limiter.py b/new-planet-backend/app/middleware/rate_limiter.py new file mode 100644 index 0000000..e77397b --- /dev/null +++ b/new-planet-backend/app/middleware/rate_limiter.py @@ -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 + diff --git a/new-planet-backend/app/models/__init__.py b/new-planet-backend/app/models/__init__.py new file mode 100644 index 0000000..0b1b117 --- /dev/null +++ b/new-planet-backend/app/models/__init__.py @@ -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", +] + diff --git a/new-planet-backend/app/models/ai_conversation.py b/new-planet-backend/app/models/ai_conversation.py new file mode 100644 index 0000000..fe36551 --- /dev/null +++ b/new-planet-backend/app/models/ai_conversation.py @@ -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") + diff --git a/new-planet-backend/app/models/reward.py b/new-planet-backend/app/models/reward.py new file mode 100644 index 0000000..2bf24e3 --- /dev/null +++ b/new-planet-backend/app/models/reward.py @@ -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") + diff --git a/new-planet-backend/app/models/schedule.py b/new-planet-backend/app/models/schedule.py new file mode 100644 index 0000000..3a10a0b --- /dev/null +++ b/new-planet-backend/app/models/schedule.py @@ -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") + diff --git a/new-planet-backend/app/models/task.py b/new-planet-backend/app/models/task.py new file mode 100644 index 0000000..49d372f --- /dev/null +++ b/new-planet-backend/app/models/task.py @@ -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") + diff --git a/new-planet-backend/app/models/user.py b/new-planet-backend/app/models/user.py new file mode 100644 index 0000000..c46ce1e --- /dev/null +++ b/new-planet-backend/app/models/user.py @@ -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") + diff --git a/new-planet-backend/app/schemas/__init__.py b/new-planet-backend/app/schemas/__init__.py new file mode 100644 index 0000000..69d498e --- /dev/null +++ b/new-planet-backend/app/schemas/__init__.py @@ -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", +] + diff --git a/new-planet-backend/app/schemas/ai.py b/new-planet-backend/app/schemas/ai.py new file mode 100644 index 0000000..3f17ed9 --- /dev/null +++ b/new-planet-backend/app/schemas/ai.py @@ -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 + diff --git a/new-planet-backend/app/schemas/reward.py b/new-planet-backend/app/schemas/reward.py new file mode 100644 index 0000000..354a2d8 --- /dev/null +++ b/new-planet-backend/app/schemas/reward.py @@ -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 + diff --git a/new-planet-backend/app/schemas/schedule.py b/new-planet-backend/app/schemas/schedule.py new file mode 100644 index 0000000..a327e85 --- /dev/null +++ b/new-planet-backend/app/schemas/schedule.py @@ -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] = [] + diff --git a/new-planet-backend/app/schemas/task.py b/new-planet-backend/app/schemas/task.py new file mode 100644 index 0000000..cc601a1 --- /dev/null +++ b/new-planet-backend/app/schemas/task.py @@ -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 + diff --git a/new-planet-backend/app/schemas/token.py b/new-planet-backend/app/schemas/token.py new file mode 100644 index 0000000..cf9d250 --- /dev/null +++ b/new-planet-backend/app/schemas/token.py @@ -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 + diff --git a/new-planet-backend/app/schemas/user.py b/new-planet-backend/app/schemas/user.py new file mode 100644 index 0000000..990d566 --- /dev/null +++ b/new-planet-backend/app/schemas/user.py @@ -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 + diff --git a/new-planet-backend/app/services/__init__.py b/new-planet-backend/app/services/__init__.py new file mode 100644 index 0000000..2bc7d48 --- /dev/null +++ b/new-planet-backend/app/services/__init__.py @@ -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", +] + diff --git a/new-planet-backend/app/services/auth_service.py b/new-planet-backend/app/services/auth_service.py new file mode 100644 index 0000000..cb796c1 --- /dev/null +++ b/new-planet-backend/app/services/auth_service.py @@ -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() + diff --git a/new-planet-backend/app/services/cache_service.py b/new-planet-backend/app/services/cache_service.py new file mode 100644 index 0000000..18b500e --- /dev/null +++ b/new-planet-backend/app/services/cache_service.py @@ -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() + diff --git a/new-planet-backend/app/services/chat_service.py b/new-planet-backend/app/services/chat_service.py new file mode 100644 index 0000000..c2a7dde --- /dev/null +++ b/new-planet-backend/app/services/chat_service.py @@ -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() + diff --git a/new-planet-backend/app/services/gigachat_service.py b/new-planet-backend/app/services/gigachat_service.py new file mode 100644 index 0000000..57ce000 --- /dev/null +++ b/new-planet-backend/app/services/gigachat_service.py @@ -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() + diff --git a/new-planet-backend/app/services/schedule_generator.py b/new-planet-backend/app/services/schedule_generator.py new file mode 100644 index 0000000..e5be809 --- /dev/null +++ b/new-planet-backend/app/services/schedule_generator.py @@ -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() + diff --git a/new-planet-backend/app/services/storage_service.py b/new-planet-backend/app/services/storage_service.py new file mode 100644 index 0000000..5d331f7 --- /dev/null +++ b/new-planet-backend/app/services/storage_service.py @@ -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() + diff --git a/new-planet-backend/app/utils/__init__.py b/new-planet-backend/app/utils/__init__.py new file mode 100644 index 0000000..1e0eac1 --- /dev/null +++ b/new-planet-backend/app/utils/__init__.py @@ -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", +] + diff --git a/new-planet-backend/app/utils/helpers.py b/new-planet-backend/app/utils/helpers.py new file mode 100644 index 0000000..5eb7dc1 --- /dev/null +++ b/new-planet-backend/app/utils/helpers.py @@ -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) + diff --git a/new-planet-backend/app/utils/validators.py b/new-planet-backend/app/utils/validators.py new file mode 100644 index 0000000..1698c8f --- /dev/null +++ b/new-planet-backend/app/utils/validators.py @@ -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 + diff --git a/new-planet-backend/docker/Dockerfile b/new-planet-backend/docker/Dockerfile new file mode 100644 index 0000000..8387518 --- /dev/null +++ b/new-planet-backend/docker/Dockerfile @@ -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"] + diff --git a/new-planet-backend/docker/docker-compose.yml b/new-planet-backend/docker/docker-compose.yml new file mode 100644 index 0000000..ff323b9 --- /dev/null +++ b/new-planet-backend/docker/docker-compose.yml @@ -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: + diff --git a/new-planet-backend/pyproject.toml b/new-planet-backend/pyproject.toml new file mode 100644 index 0000000..e69de29 diff --git a/new-planet-backend/requirements.txt b/new-planet-backend/requirements.txt new file mode 100644 index 0000000..6430800 --- /dev/null +++ b/new-planet-backend/requirements.txt @@ -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 diff --git a/new-planet-backend/tests/__init__.py b/new-planet-backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/new-planet-backend/tests/conftest.py b/new-planet-backend/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/new-planet-backend/tests/integration/__init__.py b/new-planet-backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/new-planet-backend/tests/unit/__init__.py b/new-planet-backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29