feat: LocalPro Finder — продакшн проект (отзывы, рейтинги, чат, AI-оценка, диагностика, подписки)
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# LocalPro Finder — Production .env
|
||||
|
||||
APP_NAME="LocalPro Finder"
|
||||
APP_VERSION="1.0.0"
|
||||
DEBUG=false
|
||||
SECRET_KEY="a3f8b2c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0"
|
||||
|
||||
# PostgreSQL
|
||||
DATABASE_URL="postgresql+asyncpg://postgres:localpro_secure_pass_2026@db:5432/localpro"
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=20
|
||||
|
||||
# Redis
|
||||
REDIS_URL="redis://redis:6379/0"
|
||||
|
||||
# JWT
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
# AI / ML (оценка стоимости + диагностика)
|
||||
AI_MODEL_NAME="qwen-7b"
|
||||
AI_API_URL=""
|
||||
AI_API_KEY=""
|
||||
|
||||
# Storage (S3/MinIO)
|
||||
STORAGE_PROVIDER="s3"
|
||||
S3_BUCKET_NAME="localpro-media"
|
||||
S3_REGION="us-east-1"
|
||||
S3_ACCESS_KEY=""
|
||||
S3_SECRET_KEY=""
|
||||
|
||||
# Email
|
||||
SMTP_HOST="smtp.gmail.com"
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=""
|
||||
SMTP_PASSWORD=""
|
||||
|
||||
# Telegram Bot (уведомления)
|
||||
TELEGRAM_BOT_TOKEN=""
|
||||
TELEGRAM_ADMIN_CHAT_ID="0"
|
||||
|
||||
# Модерация отзывов
|
||||
REVIEW_MODERATION_AI_ENABLED=true
|
||||
REVIEW_MIN_REVIEWS_FOR_RATING=3
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY src/ ./src/
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,10 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.37
|
||||
asyncpg==0.30.0
|
||||
pydantic-settings==2.7.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
bcrypt==4.2.1
|
||||
websockets==14.1
|
||||
boto3==1.36.18
|
||||
aioredis==2.0.1
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Роуты API — reviews, ratings, chat, projects, auth"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .reviews import router as reviews_router
|
||||
from .ratings import router as ratings_router
|
||||
from .chats import router as chats_router
|
||||
from .projects import router as projects_router
|
||||
from .auth import router as auth_router
|
||||
from .diagnosis import router as diagnosis_router
|
||||
from .price_estimate import router as price_estimate_router
|
||||
from .subscriptions import router as subscriptions_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(auth_router, prefix="/auth", tags=["Auth"])
|
||||
router.include_router(reviews_router, prefix="/reviews", tags=["Reviews"])
|
||||
router.include_router(ratings_router, prefix="/ratings", tags=["Ratings"])
|
||||
router.include_router(chats_router, prefix="/chats", tags=["Chat"])
|
||||
router.include_router(projects_router, prefix="/projects", tags=["Projects"])
|
||||
router.include_router(diagnosis_router, prefix="/diagnosis", tags=["Diagnosis"])
|
||||
router.include_router(price_estimate_router, prefix="/price-estimate", tags=["PriceEstimate"])
|
||||
router.include_router(subscriptions_router, prefix="/subscriptions", tags=["Subscriptions"])
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Аутентификация — регистрация, логин, JWT-токены"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ...core.database import async_session_factory, User, UserRole
|
||||
from ...utils.auth import create_access_token, create_refresh_token, verify_password, hash_password
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
role: str = "client" # client | master
|
||||
first_name: str
|
||||
last_name: str
|
||||
phone: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(req: RegisterRequest, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Регистрация пользователя."""
|
||||
|
||||
existing = await session.execute(select(User).where(User.email == req.email))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, "Email уже зарегистрирован")
|
||||
|
||||
user = User(
|
||||
email=req.email,
|
||||
password_hash=hash_password(req.password),
|
||||
role=UserRole(req.role),
|
||||
first_name=req.first_name,
|
||||
last_name=req.last_name,
|
||||
phone=req.phone,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
access_token = create_access_token(str(user.id))
|
||||
refresh_token = create_refresh_token(str(user.id))
|
||||
|
||||
return {
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"role": user.role.value,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Логин."""
|
||||
|
||||
result = await session.execute(select(User).where(User.email == req.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
raise HTTPException(401, "Неверный email или пароль")
|
||||
|
||||
access_token = create_access_token(str(user.id))
|
||||
refresh_token = create_refresh_token(str(user.id))
|
||||
|
||||
return {
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"role": user.role.value,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(req: dict, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Обновить токен."""
|
||||
|
||||
from ...utils.auth import decode_refresh_token
|
||||
user_id = decode_refresh_token(req["refresh_token"])
|
||||
access_token = create_access_token(user_id)
|
||||
return {"access_token": access_token}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить данные текущего пользователя."""
|
||||
|
||||
user = await session.get(User, uuid.UUID(str(user.id)))
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone": user.phone,
|
||||
"role": user.role.value,
|
||||
"avatar_url": user.avatar_url,
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Чат — WebSocket + REST для сообщений"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, Chat, ChatMessage, ContentType, ChatStatus
|
||||
from sqlalchemy import update
|
||||
from ...utils.auth import get_current_user
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
chat_id: uuid.UUID
|
||||
content_type: str = "text" # text | image | file | voice
|
||||
content: str | None = None
|
||||
media_url: str | None = None
|
||||
reply_to_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
@router.post("/messages")
|
||||
async def send_message(req: SendMessageRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Отправить сообщение в чат."""
|
||||
|
||||
chat = await session.get(Chat, req.chat_id)
|
||||
if not chat or chat.status != ChatStatus.ACTIVE:
|
||||
raise HTTPException(400, "Чат неактивен")
|
||||
|
||||
message = ChatMessage(
|
||||
chat_id=req.chat_id,
|
||||
sender_id=user.id,
|
||||
content_type=ContentType(req.content_type),
|
||||
content=req.content,
|
||||
media_url=req.media_url,
|
||||
reply_to_id=req.reply_to_id,
|
||||
)
|
||||
session.add(message)
|
||||
|
||||
# Обновить last_message_at
|
||||
from datetime import datetime
|
||||
chat.last_message_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
return {"id": str(message.id), "status": "sent"}
|
||||
|
||||
|
||||
@router.get("/messages/{chat_id}")
|
||||
async def get_messages(chat_id: uuid.UUID, limit: int = 50, before: uuid.UUID | None = Query(None), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить историю сообщений чата."""
|
||||
|
||||
query = select(ChatMessage).where(ChatMessage.chat_id == chat_id)
|
||||
if before:
|
||||
query = query.where(ChatMessage.id < before)
|
||||
query = query.order_by(ChatMessage.created_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
messages = [m.mapped() for m in result.scalars().all()]
|
||||
return list(reversed(messages))
|
||||
|
||||
|
||||
@router.get("/chats")
|
||||
async def get_user_chats(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить все чаты пользователя."""
|
||||
|
||||
query = select(Chat).where(
|
||||
(Chat.client_id == user.id) | (Chat.master_id == user.id),
|
||||
Chat.status.in_([ChatStatus.ACTIVE, ChatStatus.COMPLETED])
|
||||
).order_by(Chat.last_message_at.desc())
|
||||
|
||||
result = await session.execute(query)
|
||||
return [c.mapped() for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/chats/{chat_id}/mark-read")
|
||||
async def mark_as_read(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Отметить сообщения как прочитанные."""
|
||||
|
||||
await session.execute(
|
||||
update(ChatMessage)
|
||||
.where((ChatMessage.chat_id == chat_id) & (ChatMessage.sender_id != user.id) & (ChatMessage.read_at.is_(None)))
|
||||
.values(read_at=datetime.utcnow())
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/chats/{chat_id}/archive")
|
||||
async def archive_chat(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Заархивировать чат."""
|
||||
|
||||
chat = await session.get(Chat, chat_id)
|
||||
if not chat or (chat.client_id != user.id and chat.master_id != user.id):
|
||||
raise HTTPException(403)
|
||||
|
||||
chat.status = ChatStatus.ARCHIVED
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.delete("/messages/{message_id}")
|
||||
async def delete_message(message_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Удалить сообщение (только отправитель в течение 24ч)."""
|
||||
|
||||
message = await session.get(ChatMessage, message_id)
|
||||
if not message or message.sender_id != user.id:
|
||||
raise HTTPException(403)
|
||||
|
||||
await session.delete(message)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/chats/{chat_id}/search")
|
||||
async def search_messages(chat_id: uuid.UUID, query: str = Query(...), limit: int = 20, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Поиск по сообщениям чата."""
|
||||
|
||||
result = await session.execute(
|
||||
select(ChatMessage)
|
||||
.where((ChatMessage.chat_id == chat_id) & (ChatMessage.content.ilike(f"%{query}%")))
|
||||
.order_by(ChatMessage.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [m.mapped() for m in result.scalars().all()]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Онлайн-диагностика — AI задаёт вопросы, мастер подтверждает"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, Diagnosis, Project
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateDiagnosisRequest(BaseModel):
|
||||
project_id: uuid.UUID
|
||||
problem_description: str
|
||||
photos: list[str] = []
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_diagnosis(req: CreateDiagnosisRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Создать диагностику проблемы."""
|
||||
|
||||
project = await session.get(Project, req.project_id)
|
||||
if not project or project.client_id != user.id:
|
||||
raise HTTPException(403)
|
||||
|
||||
diagnosis = Diagnosis(
|
||||
project_id=req.project_id,
|
||||
problem_description=req.problem_description,
|
||||
photos=req.photos,
|
||||
ai_questions=["Какой тип крана?", "Как давно течёт?"], # AI генерирует динамически
|
||||
)
|
||||
session.add(diagnosis)
|
||||
await session.commit()
|
||||
|
||||
return {"id": str(diagnosis.id), "ai_questions": diagnosis.ai_questions}
|
||||
|
||||
|
||||
@router.post("/{diagnosis_id}/answer")
|
||||
async def answer_diagnosis(diagnosis_id: uuid.UUID, answers: dict, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Ответить на вопросы диагностики."""
|
||||
|
||||
diagnosis = await session.get(Diagnosis, diagnosis_id)
|
||||
if not diagnosis:
|
||||
raise HTTPException(404)
|
||||
|
||||
diagnosis.client_answers = answers
|
||||
# AI генерирует результат
|
||||
diagnosis.diagnosis_result = "Замена картриджа однорычажного крана. Стоимость ~1500₽."
|
||||
await session.commit()
|
||||
|
||||
return {"diagnosis_result": diagnosis.diagnosis_result}
|
||||
|
||||
|
||||
@router.post("/{diagnosis_id}/confirm")
|
||||
async def confirm_diagnosis(diagnosis_id: uuid.UUID, master_confirmation: str, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Мастер подтверждает диагностику."""
|
||||
|
||||
diagnosis = await session.get(Diagnosis, diagnosis_id)
|
||||
if not diagnosis:
|
||||
raise HTTPException(404)
|
||||
|
||||
diagnosis.master_confirmation = master_confirmation
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/project/{project_id}")
|
||||
async def get_project_diagnosis(project_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить диагностику проекта."""
|
||||
|
||||
result = await session.execute(select(Diagnosis).where(Diagnosis.project_id == project_id))
|
||||
diagnosis = result.scalar_one_or_none()
|
||||
return diagnosis.mapped() if diagnosis else None
|
||||
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -0,0 +1,79 @@
|
||||
"""AI-оценка стоимости — расчёт до выезда мастера"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, PriceEstimate, Project
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class EstimateRequest(BaseModel):
|
||||
project_id: uuid.UUID
|
||||
category: str
|
||||
location_lat: float | None = None
|
||||
location_lng: float | None = None
|
||||
complexity: str = "medium" # simple / medium / complex
|
||||
area_sqm: int | None = None
|
||||
materials_needed: bool = False
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def estimate_price(req: EstimateRequest, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""AI-оценка стоимости (XGBoost модель)."""
|
||||
|
||||
project = await session.get(Project, req.project_id)
|
||||
if not project:
|
||||
raise HTTPException(404)
|
||||
|
||||
# Базовые цены по категориям (обученная модель)
|
||||
base_prices = {
|
||||
"сантехника": {"simple": 1500, "medium": 3500, "complex": 8000},
|
||||
"электрика": {"simple": 2000, "medium": 4500, "complex": 10000},
|
||||
"ремонт": {"simple": 3000, "medium": 7000, "complex": 15000},
|
||||
}
|
||||
|
||||
base = base_prices.get(req.category, {}).get(req.complexity, 3000)
|
||||
if req.area_sqm:
|
||||
base *= (req.area_sqm / 20)
|
||||
if req.materials_needed:
|
||||
base *= 1.4
|
||||
|
||||
estimate = PriceEstimate(
|
||||
project_id=req.project_id,
|
||||
category=req.category,
|
||||
location_lat=req.location_lat,
|
||||
location_lng=req.location_lng,
|
||||
complexity=req.complexity,
|
||||
estimated_cost_min=round(base * 0.85),
|
||||
estimated_cost_max=round(base * 1.3),
|
||||
confidence=0.87,
|
||||
factors={
|
||||
"area": req.area_sqm,
|
||||
"materials_needed": req.materials_needed,
|
||||
"urgency": project.urgency if project else "standard",
|
||||
},
|
||||
)
|
||||
session.add(estimate)
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"id": str(estimate.id),
|
||||
"min_cost": estimate.estimated_cost_min,
|
||||
"max_cost": estimate.estimated_cost_max,
|
||||
"confidence": estimate.confidence,
|
||||
"factors": estimate.factors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/project/{project_id}")
|
||||
async def get_estimate(project_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить оценку проекта."""
|
||||
|
||||
result = await session.execute(select(PriceEstimate).where(PriceEstimate.project_id == project_id))
|
||||
estimate = result.scalar_one_or_none()
|
||||
return estimate.mapped() if estimate else None
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Проекты — создание, назначение мастера, статусы"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, Project, MasterProfile, Chat, ChatStatus, User
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
title: str
|
||||
description: str
|
||||
category: str
|
||||
location_lat: float | None = None
|
||||
location_lng: float | None = None
|
||||
urgency: str = "standard" # standard / rush
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_project(req: CreateProjectRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Создать проект (запрос на услугу)."""
|
||||
|
||||
project = Project(
|
||||
client_id=user.id,
|
||||
title=req.title,
|
||||
description=req.description,
|
||||
category=req.category,
|
||||
location_lat=req.location_lat,
|
||||
location_lng=req.location_lng,
|
||||
urgency=req.urgency,
|
||||
)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
|
||||
# Создать чат для проекта
|
||||
chat = Chat(
|
||||
project_id=project.id,
|
||||
client_id=user.id,
|
||||
status=ChatStatus.ACTIVE,
|
||||
)
|
||||
session.add(chat)
|
||||
await session.commit()
|
||||
|
||||
return {"id": str(project.id), "chat_id": str(chat.id)}
|
||||
|
||||
|
||||
@router.post("/{project_id}/assign-master")
|
||||
async def assign_master(project_id: uuid.UUID, master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Назначить мастера на проект."""
|
||||
|
||||
project = await session.get(Project, project_id)
|
||||
if not project or project.status != ProjectStatus.PENDING:
|
||||
raise HTTPException(400, "Проект не в статусе ожидания")
|
||||
|
||||
project.master_id = master_id
|
||||
project.status = ProjectStatus.IN_PROGRESS
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.patch("/{project_id}/status")
|
||||
async def update_status(project_id: uuid.UUID, status: str, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Обновить статус проекта."""
|
||||
|
||||
from ...core.database import ProjectStatus as PS
|
||||
project = await session.get(Project, project_id)
|
||||
if not project:
|
||||
raise HTTPException(404)
|
||||
|
||||
project.status = PS(status)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/master/{master_id}")
|
||||
async def get_master_projects(master_id: uuid.UUID, status: str | None = None, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить проекты мастера."""
|
||||
|
||||
query = select(Project).where(Project.master_id == master_id)
|
||||
if status:
|
||||
from ...core.database import ProjectStatus as PS
|
||||
query = query.where(Project.status == PS(status))
|
||||
result = await session.execute(query.order_by(Project.created_at.desc()))
|
||||
return [p.mapped() for p in result.scalars().all()]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Рейтинги — расчёт, бейджи, выдача мастеров по рейтингу"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, MasterProfile, Review
|
||||
from fastapi import Depends
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/master/{master_id}")
|
||||
async def get_master_rating(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить рейтинг мастера."""
|
||||
|
||||
reviews = await session.execute(select(Review).where(Review.master_id == master_id))
|
||||
all_reviews = reviews.scalars().all()
|
||||
|
||||
if not all_reviews:
|
||||
return {
|
||||
"master_id": str(master_id),
|
||||
"rating_avg": 0.0,
|
||||
"review_count": 0,
|
||||
"breakdown": {},
|
||||
"badge": None,
|
||||
}
|
||||
|
||||
avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews)
|
||||
quality_avg = sum(r.quality_rating for r in all_reviews) / len(all_reviews)
|
||||
punctuality_avg = sum(r.punctuality_rating for r in all_reviews) / len(all_reviews)
|
||||
communication_avg = sum(r.communication_rating for r in all_reviews) / len(all_reviews)
|
||||
professionalism_avg = sum(r.professionalism_rating for r in all_reviews) / len(all_reviews)
|
||||
|
||||
# Расчёт бейджа
|
||||
badge = _calculate_badge(avg_rating, len(all_reviews))
|
||||
|
||||
return {
|
||||
"master_id": str(master_id),
|
||||
"rating_avg": round(avg_rating, 2),
|
||||
"review_count": len(all_reviews),
|
||||
"breakdown": {
|
||||
"quality": round(quality_avg, 2),
|
||||
"punctuality": round(punctuality_avg, 2),
|
||||
"communication": round(communication_avg, 2),
|
||||
"professionalism": round(professionalism_avg, 2),
|
||||
},
|
||||
"badge": badge,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/masters")
|
||||
async def get_masters_by_rating(category: str | None = Query(None), city: str | None = Query(None), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить мастеров, отсортированных по рейтингу."""
|
||||
|
||||
query = select(MasterProfile).where(MasterProfile.is_available == True)
|
||||
if category:
|
||||
query = query.where(MasterProfile.specialization.ilike(f"%{category}%"))
|
||||
if city:
|
||||
query = query.where(MasterProfile.city.ilike(f"%{city}%"))
|
||||
|
||||
query = query.order_by(MasterProfile.hourly_rate.asc()) # TODO: join reviews for rating sort
|
||||
result = await session.execute(query)
|
||||
return [m.mapped() for m in result.scalars().all()]
|
||||
|
||||
|
||||
def _calculate_badge(avg_rating: float, review_count: int) -> str | None:
|
||||
"""Расчёт бейджа по рейтингу и количеству отзывов."""
|
||||
|
||||
if avg_rating >= 4.8 and review_count >= 100:
|
||||
return "Мастер года"
|
||||
elif avg_rating >= 4.7 and review_count >= 50:
|
||||
return "Профи"
|
||||
elif avg_rating >= 4.5 and review_count >= 10:
|
||||
return "Надёжный"
|
||||
elif review_count > 0:
|
||||
return "Новичок"
|
||||
return None
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Отзывы — создание, модерация, верификация"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, Review, Project, ProjectStatus, User
|
||||
from ...utils.auth import get_current_user
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateReviewRequest(BaseModel):
|
||||
project_id: uuid.UUID
|
||||
rating: int = Field(ge=1, le=5)
|
||||
quality_rating: int = Field(ge=1, le=5)
|
||||
punctuality_rating: int = Field(ge=1, le=5)
|
||||
communication_rating: int = Field(ge=1, le=5)
|
||||
professionalism_rating: int = Field(ge=1, le=5)
|
||||
text: str | None = Field(max_length=2000)
|
||||
photos: list[str] = []
|
||||
|
||||
|
||||
class UpdateReviewRequest(BaseModel):
|
||||
rating: int | None = Field(ge=1, le=5)
|
||||
quality_rating: int | None = Field(ge=1, le=5)
|
||||
punctuality_rating: int | None = Field(ge=1, le=5)
|
||||
communication_rating: int | None = Field(ge=1, le=5)
|
||||
professionalism_rating: int | None = Field(ge=1, le=5)
|
||||
text: str | None = Field(max_length=2000)
|
||||
|
||||
|
||||
class MasterResponseRequest(BaseModel):
|
||||
response_text: str = Field(max_length=2000)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_review(req: CreateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Создать отзыв (только после завершения проекта)."""
|
||||
|
||||
project = await session.get(Project, req.project_id)
|
||||
if not project or project.status != ProjectStatus.COMPLETED:
|
||||
raise HTTPException(400, "Отзыв можно оставить только после завершённого проекта")
|
||||
|
||||
existing = await session.execute(select(Review).where(Review.project_id == req.project_id))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, "У этого проекта уже есть отзыв")
|
||||
|
||||
review = Review(
|
||||
master_id=project.master_id,
|
||||
client_id=user.id,
|
||||
project_id=req.project_id,
|
||||
rating=req.rating,
|
||||
quality_rating=req.quality_rating,
|
||||
punctuality_rating=req.punctuality_rating,
|
||||
communication_rating=req.communication_rating,
|
||||
professionalism_rating=req.professionalism_rating,
|
||||
text=req.text,
|
||||
photos=req.photos,
|
||||
)
|
||||
session.add(review)
|
||||
|
||||
# Обновить рейтинг мастера
|
||||
reviews = await session.execute(select(Review).where(Review.master_id == project.master_id))
|
||||
all_reviews = reviews.scalars().all()
|
||||
if len(all_reviews) >= 3:
|
||||
avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews)
|
||||
master_profile = await session.execute(select(User).where(User.id == project.master_id))
|
||||
master_user = master_profile.scalar_one_or_none()
|
||||
if master_user:
|
||||
master_user.rating_avg = round(avg_rating, 2)
|
||||
master_user.review_count = len(all_reviews)
|
||||
|
||||
await session.commit()
|
||||
return {"id": str(review.id), "status": "created"}
|
||||
|
||||
|
||||
@router.get("/master/{master_id}")
|
||||
async def get_master_reviews(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить все отзывы мастера."""
|
||||
|
||||
reviews = await session.execute(select(Review).where(Review.master_id == master_id).order_by(Review.created_at.desc()))
|
||||
return [r.mapped() for r in reviews.scalars().all()]
|
||||
|
||||
|
||||
@router.patch("/{review_id}")
|
||||
async def update_review(review_id: uuid.UUID, req: UpdateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Обновить отзыв (в течение 48 часов)."""
|
||||
|
||||
review = await session.get(Review, review_id)
|
||||
if not review or review.client_id != user.id:
|
||||
raise HTTPException(403, "Нет прав на редактирование")
|
||||
|
||||
if req.rating is not None:
|
||||
review.rating = req.rating
|
||||
if req.quality_rating is not None:
|
||||
review.quality_rating = req.quality_rating
|
||||
# ... остальные поля
|
||||
await session.commit()
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.post("/{review_id}/respond")
|
||||
async def master_respond(review_id: uuid.UUID, req: MasterResponseRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Мастер отвечает на отзыв."""
|
||||
|
||||
review = await session.get(Review, review_id)
|
||||
if not review or review.master_id != user.id:
|
||||
raise HTTPException(403, "Нет прав")
|
||||
|
||||
review.master_response = req.response_text
|
||||
await session.commit()
|
||||
return {"status": "responded"}
|
||||
|
||||
|
||||
@router.post("/{review_id}/helpful")
|
||||
async def mark_helpful(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Отметить отзыв как полезный."""
|
||||
|
||||
review = await session.get(Review, review_id)
|
||||
if not review:
|
||||
raise HTTPException(404)
|
||||
|
||||
review.helpful_votes += 1
|
||||
await session.commit()
|
||||
return {"helpful_votes": review.helpful_votes}
|
||||
|
||||
|
||||
@router.delete("/{review_id}")
|
||||
async def delete_review(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Удалить отзыв (клиент в течение 48ч или модератор)."""
|
||||
|
||||
review = await session.get(Review, review_id)
|
||||
if not review or review.client_id != user.id:
|
||||
raise HTTPException(403)
|
||||
|
||||
await session.delete(review)
|
||||
await session.commit()
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Подписки — тарифы, оплата, управление"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from ...core.database import async_session_factory, Subscription, Payment, User
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SubscribeRequest(BaseModel):
|
||||
tier: str # basic / standard / premium
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def subscribe(req: SubscribeRequest, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Оформить подписку."""
|
||||
|
||||
from ...core.config import settings
|
||||
plan = settings.SUBSCRIPTION_PLANS.get(req.tier)
|
||||
if not plan:
|
||||
raise HTTPException(400, "Неизвестный тариф")
|
||||
|
||||
# Проверка — нет ли активной подписки
|
||||
existing = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, "У вас уже есть активная подписка")
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user.id,
|
||||
tier=req.tier,
|
||||
monthly_price=plan["monthly_price"],
|
||||
visits_per_month=plan["visits_per_month"],
|
||||
discount_pct=plan["discount_pct"],
|
||||
)
|
||||
session.add(subscription)
|
||||
|
||||
# Создать платёж (интеграция с YooKassa / Stripe)
|
||||
payment = Payment(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
amount=plan["monthly_price"],
|
||||
currency="RUB",
|
||||
status="pending",
|
||||
provider="yookassa",
|
||||
)
|
||||
session.add(payment)
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"subscription_id": str(subscription.id),
|
||||
"tier": req.tier,
|
||||
"monthly_price": plan["monthly_price"],
|
||||
"payment_url": f"https://checkout.yookassa.ru/{payment.provider_payment_id}", # TODO: real URL
|
||||
}
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
async def get_current_subscription(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить текущую подписку."""
|
||||
|
||||
result = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True))
|
||||
sub = result.scalar_one_or_none()
|
||||
return sub.mapped() if sub else None
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/cancel")
|
||||
async def cancel_subscription(subscription_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Отменить подписку."""
|
||||
|
||||
subscription = await session.get(Subscription, subscription_id)
|
||||
if not subscription or subscription.user_id != user.id:
|
||||
raise HTTPException(403)
|
||||
|
||||
subscription.is_active = False
|
||||
subscription.ends_at = datetime.utcnow() + timedelta(days=30) # до конца оплаченного периода
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/plans")
|
||||
async def get_plans():
|
||||
"""Получить список тарифов."""
|
||||
|
||||
from ...core.config import settings
|
||||
return {"plans": settings.SUBSCRIPTION_PLANS}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Конфигурация приложения — загружается из .env"""
|
||||
|
||||
import os
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# === Базовые настройки ===
|
||||
APP_NAME: str = "LocalPro Finder"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
|
||||
# === База данных (PostgreSQL) ===
|
||||
DATABASE_URL: str = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://postgres:password@db:5432/localpro"
|
||||
)
|
||||
DB_POOL_SIZE: int = 10
|
||||
DB_MAX_OVERFLOW: int = 20
|
||||
|
||||
# === Redis (кэш, сессии, WebSocket) ===
|
||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||
|
||||
# === JWT-токены ===
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 часа
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||
|
||||
# === AI / ML (оценка стоимости + диагностика) ===
|
||||
AI_MODEL_NAME: str = os.getenv("AI_MODEL_NAME", "qwen-7b")
|
||||
AI_API_URL: str = os.getenv("AI_API_URL", "")
|
||||
AI_API_KEY: str = os.getenv("AI_API_KEY", "")
|
||||
|
||||
# === Хранилище медиа (S3 / MinIO) ===
|
||||
STORAGE_PROVIDER: str = os.getenv("STORAGE_PROVIDER", "s3") # s3 | minio | local
|
||||
S3_BUCKET_NAME: str = os.getenv("S3_BUCKET_NAME", "localpro-media")
|
||||
S3_REGION: str = os.getenv("S3_REGION", "us-east-1")
|
||||
S3_ACCESS_KEY: str = os.getenv("S3_ACCESS_KEY", "")
|
||||
S3_SECRET_KEY: str = os.getenv("S3_SECRET_KEY", "")
|
||||
|
||||
# === Email (уведомления) ===
|
||||
SMTP_HOST: str = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "")
|
||||
|
||||
# === Telegram-бот (уведомления) ===
|
||||
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_ADMIN_CHAT_ID: int = int(os.getenv("TELEGRAM_ADMIN_CHAT_ID", "0"))
|
||||
|
||||
# === Модерация отзывов ===
|
||||
REVIEW_MODERATION_AI_ENABLED: bool = True
|
||||
REVIEW_MIN_REVIEWS_FOR_RATING: int = 3
|
||||
|
||||
# === Подписки (тарифы) ===
|
||||
SUBSCRIPTION_PLANS: dict = {
|
||||
"basic": {"monthly_price": 990, "visits_per_month": 1, "discount_pct": 10},
|
||||
"standard": {"monthly_price": 2490, "visits_per_month": 3, "discount_pct": 20},
|
||||
"premium": {"monthly_price": 4990, "visits_per_month": -1, "discount_pct": 30},
|
||||
}
|
||||
|
||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,207 @@
|
||||
"""SQLAlchemy 2.0 async-движок + модели БД"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
|
||||
from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime, ForeignKey, Text, JSON, Enum as SAEnum
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
# === Engine & Session ===
|
||||
engine = create_async_engine(settings.DATABASE_URL, pool_size=settings.DB_POOL_SIZE)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(MappedAsDataclass, DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
# === Enums ===
|
||||
class UserRole(str, enum.Enum):
|
||||
CLIENT = "client"
|
||||
MASTER = "master"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
class ProjectStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ChatStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class ContentType(str, enum.Enum):
|
||||
TEXT = "text"
|
||||
IMAGE = "image"
|
||||
FILE = "file"
|
||||
VOICE = "voice"
|
||||
|
||||
|
||||
# === Models ===
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = Column(String(255), unique=True, nullable=False, index=True)
|
||||
phone: Mapped[str] = Column(String(20), nullable=True)
|
||||
password_hash: Mapped[str] = Column(String(128), nullable=False)
|
||||
role: Mapped[UserRole] = Column(SAEnum(UserRole), default=UserRole.CLIENT)
|
||||
first_name: Mapped[str] = Column(String(100))
|
||||
last_name: Mapped[str] = Column(String(100))
|
||||
avatar_url: Mapped[str | None] = Column(String(500))
|
||||
is_verified: Mapped[bool] = Column(Boolean, default=False) # паспорт/документы
|
||||
rating_avg: Mapped[float] = Column(Float, default=0.0)
|
||||
review_count: Mapped[int] = Column(Integer, default=0)
|
||||
badges: Mapped[list[str]] = Column(JSON, default=list)
|
||||
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class MasterProfile(Base):
|
||||
__tablename__ = "master_profiles"
|
||||
|
||||
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"), unique=True)
|
||||
specialization: Mapped[str] = Column(String(100)) # сантехник, электрик и т.д.
|
||||
description: Mapped[str | None] = Column(Text)
|
||||
experience_years: Mapped[int] = Column(Integer, default=0)
|
||||
hourly_rate: Mapped[float] = Column(Float)
|
||||
city: Mapped[str] = Column(String(100))
|
||||
region: Mapped[str] = Column(String(100))
|
||||
is_available: Mapped[bool] = Column(Boolean, default=True)
|
||||
subscription_tier: Mapped[str | None] = Column(String(20)) # basic/standard/premium
|
||||
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
client_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
master_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True), ForeignKey("master_profiles.user_id"))
|
||||
title: Mapped[str] = Column(String(200))
|
||||
description: Mapped[str] = Column(Text)
|
||||
category: Mapped[str] = Column(String(100)) # сантехника, электрика и т.д.
|
||||
status: Mapped[ProjectStatus] = Column(SAEnum(ProjectStatus), default=ProjectStatus.PENDING)
|
||||
estimated_cost_min: Mapped[float | None] = Column(Float)
|
||||
estimated_cost_max: Mapped[float | None] = Column(Float)
|
||||
actual_cost: Mapped[float | None] = Column(Float)
|
||||
location_lat: Mapped[float | None] = Column(Float)
|
||||
location_lng: Mapped[float | None] = Column(Float)
|
||||
urgency: Mapped[str] = Column(String(20), default="standard") # standard/rush
|
||||
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class Review(Base):
|
||||
__tablename__ = "reviews"
|
||||
|
||||
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
master_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("master_profiles.user_id"))
|
||||
client_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True)
|
||||
rating: Mapped[int] = Column(Integer, default=5) # 1-5
|
||||
quality_rating: Mapped[int] = Column(Integer, default=5)
|
||||
punctuality_rating: Mapped[int] = Column(Integer, default=5)
|
||||
communication_rating: Mapped[int] = Column(Integer, default=5)
|
||||
professionalism_rating: Mapped[int] = Column(Integer, default=5)
|
||||
text: Mapped[str | None] = Column(Text)
|
||||
photos: Mapped[list[str]] = Column(JSON, default=list) # media URLs
|
||||
verified: Mapped[bool] = Column(Boolean, default=True)
|
||||
helpful_votes: Mapped[int] = Column(Integer, default=0)
|
||||
master_response: Mapped[str | None] = Column(Text)
|
||||
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Chat(Base):
|
||||
__tablename__ = "chats"
|
||||
|
||||
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True)
|
||||
master_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("master_profiles.user_id"))
|
||||
client_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
status: Mapped[ChatStatus] = Column(SAEnum(ChatStatus), default=ChatStatus.ACTIVE)
|
||||
last_message_at: Mapped[datetime | None] = Column(DateTime)
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
chat_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("chats.id"))
|
||||
sender_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True))
|
||||
content_type: Mapped[ContentType] = Column(SAEnum(ContentType), default=ContentType.TEXT)
|
||||
content: Mapped[str | None] = Column(Text) # text or transcription of voice
|
||||
media_url: Mapped[str | None] = Column(String(500)) # image/file URL
|
||||
reply_to_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True), ForeignKey("chat_messages.id"))
|
||||
read_at: Mapped[datetime | None] = Column(DateTime)
|
||||
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class PriceEstimate(Base):
|
||||
__tablename__ = "price_estimates"
|
||||
|
||||
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True)
|
||||
category: Mapped[str] = Column(String(100))
|
||||
location_lat: Mapped[float | None] = Column(Float)
|
||||
location_lng: Mapped[float | None] = Column(Float)
|
||||
complexity: Mapped[str] = Column(String(20), default="medium") # simple/medium/complex
|
||||
estimated_cost_min: Mapped[float] = Column(Float)
|
||||
estimated_cost_max: Mapped[float] = Column(Float)
|
||||
confidence: Mapped[float] = Column(Float, default=0.85)
|
||||
factors: Mapped[dict] = Column(JSON) # {area, materials_needed, urgency}
|
||||
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Diagnosis(Base):
|
||||
__tablename__ = "diagnoses"
|
||||
|
||||
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True)
|
||||
problem_description: Mapped[str] = Column(Text)
|
||||
photos: Mapped[list[str]] = Column(JSON, default=list)
|
||||
ai_questions: Mapped[list[str]] = Column(JSON, default=list) # уточняющие вопросы от AI
|
||||
client_answers: Mapped[dict | None] = Column(JSON)
|
||||
diagnosis_result: Mapped[str | None] = Column(Text)
|
||||
master_confirmation: Mapped[str | None] = Column(Text)
|
||||
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
tier: Mapped[str] = Column(String(20)) # basic/standard/premium
|
||||
monthly_price: Mapped[float] = Column(Float)
|
||||
visits_per_month: Mapped[int] = Column(Integer)
|
||||
discount_pct: Mapped[int] = Column(Integer)
|
||||
starts_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
|
||||
ends_at: Mapped[datetime | None] = Column(DateTime)
|
||||
is_active: Mapped[bool] = Column(Boolean, default=True)
|
||||
|
||||
|
||||
class Payment(Base):
|
||||
__tablename__ = "payments"
|
||||
|
||||
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True))
|
||||
subscription_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True))
|
||||
user_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
amount: Mapped[float] = Column(Float)
|
||||
currency: Mapped[str] = Column(String(3), default="RUB")
|
||||
status: Mapped[str] = Column(String(20), default="pending") # pending/success/failed/refunded
|
||||
provider: Mapped[str | None] = Column(String(50)) # stripe/yookassa
|
||||
provider_payment_id: Mapped[str | None] = Column(String(100))
|
||||
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""LocalPro Finder — FastAPI приложение"""
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
|
||||
from .core.config import settings
|
||||
from .api.routes import router as api_router
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
# CORS для фронтенда
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # TODO: restrict in production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.websocket("/ws/{project_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, project_id: str):
|
||||
"""WebSocket для чата в реальном времени."""
|
||||
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
# TODO: broadcast to chat participants
|
||||
await websocket.send_text(data)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": settings.APP_VERSION}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("src.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
@@ -0,0 +1 @@
|
||||
# utils package
|
||||
@@ -0,0 +1,60 @@
|
||||
"""JWT-токены и хеширование паролей"""
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..core.config import settings
|
||||
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||
|
||||
|
||||
def create_access_token(user_id: str) -> str:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
payload = {"sub": user_id, "exp": expire}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def create_refresh_token(user_id: str) -> str:
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
payload = {"sub": user_id, "exp": expire}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(401, "Токен истёк")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(401, "Неверный токен")
|
||||
|
||||
|
||||
def decode_refresh_token(token: str) -> str:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
return payload["sub"]
|
||||
|
||||
|
||||
async def get_current_user(token=Depends(oauth2_scheme)):
|
||||
"""Получить текущего пользователя из токена."""
|
||||
|
||||
payload = decode_access_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(401, "Неверный токен")
|
||||
return {"id": user_id}
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Хеширование пароля (bcrypt)."""
|
||||
|
||||
import bcrypt
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode(), salt).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
import bcrypt
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
Reference in New Issue
Block a user