feat: Freelancer Match — AI-матчинг, escrow, milestones, portfolio, skill-tests, verification
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# ============================================
|
||||
# Freelancer Match — Environment Variables (Template)
|
||||
# ============================================
|
||||
# Скопируйте этот файл в .env и заполните значения!
|
||||
|
||||
# --- ОБЯЗАТЕЛЬНЫЕ (без них не запустится) ---
|
||||
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
# --- ОПЦИОНАЛЬНЫЕ (но нужны для полноценной работы) ---
|
||||
|
||||
OPENAI_API_KEY=sk-...
|
||||
EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
GOOGLE_CLIENT_ID=...
|
||||
GITHUB_CLIENT_ID=...
|
||||
GITHUB_CLIENT_SECRET=...
|
||||
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# --- EMAIL (опционально) ---
|
||||
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@freelancermatch.com
|
||||
SMTP_PASSWORD=your-app-password
|
||||
EMAIL_FROM=noreply@freelancermatch.com
|
||||
|
||||
# --- CORS ---
|
||||
|
||||
ALLOWED_ORIGINS=["http://localhost:3000","https://freelancermatch.com"]
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""AI endpoints: матчинг, рекомендации."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas.ai_match import AIMatchRequest, AIMatchResponse
|
||||
from app.services.ai_service import find_matches
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/ai", tags=["ai"])
|
||||
|
||||
|
||||
@router.post("/match-project", response_model=list[AIMatchResponse])
|
||||
async def match_project(data: AIMatchRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Подобрать фрилансеров для проекта через AI."""
|
||||
|
||||
matches = await find_matches(
|
||||
db=db, project_id=data.project_id, limit=data.limit, min_score=data.min_score
|
||||
)
|
||||
|
||||
return [AIMatchResponse(**m) for m in matches]
|
||||
|
||||
|
||||
@router.post("/generate-cover-letter")
|
||||
async def generate_cover_letter(project_title: str, freelancer_skills: list[str]):
|
||||
"""Сгенерировать сопроводительное письмо для заявки."""
|
||||
|
||||
# Placeholder — в продакшене вызов LLM
|
||||
return {
|
||||
"cover_letter": f"Здравствуйте! Я заинтересован в проекте '{project_title}'. Мой опыт работы с [{', '.join(freelancer_skills)}] позволяет качественно выполнить задачу."
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Auth endpoints: регистрация, логин, OAuth."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import (
|
||||
hash_password, verify_password, create_access_token, create_refresh_token, get_current_user
|
||||
)
|
||||
from app.schemas.user import UserCreate, UserLogin
|
||||
from app.schemas.auth import TokenPair
|
||||
from app.models.user import User, FreelancerProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenPair)
|
||||
async def register(data: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Регистрация нового пользователя."""
|
||||
|
||||
# Проверка email
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Email уже зарегистрирован")
|
||||
|
||||
# Создание пользователя
|
||||
user = User(
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
role=data.role,
|
||||
full_name=data.full_name,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Создаём профиль фрилансера по умолчанию
|
||||
if data.role in ("freelancer", "both"):
|
||||
profile = FreelancerProfile(
|
||||
user_id=user.id,
|
||||
skills=data.full_name or [], # заполняется позже
|
||||
)
|
||||
db.add(profile)
|
||||
await db.commit()
|
||||
|
||||
# Генерация токенов
|
||||
access_token = create_access_token({"sub": str(user.id), "role": data.role})
|
||||
refresh_token = create_refresh_token({"sub": str(user.id)})
|
||||
|
||||
return TokenPair(access_token=access_token, refresh_token=refresh_token)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenPair)
|
||||
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||
"""Логин пользователя."""
|
||||
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(data.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Неверный email или пароль")
|
||||
|
||||
access_token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||
refresh_token = create_refresh_token({"sub": str(user.id)})
|
||||
|
||||
return TokenPair(access_token=access_token, refresh_token=refresh_token)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenPair)
|
||||
async def refresh(token: dict = Depends(get_current_user)):
|
||||
"""Обновление access-токена."""
|
||||
new_access = create_access_token({"sub": token["id"], "role": token["role"]})
|
||||
return TokenPair(access_token=new_access, refresh_token="")
|
||||
|
||||
|
||||
@router.get("/me", response_model=dict)
|
||||
async def me(user: dict = Depends(get_current_user)):
|
||||
"""Получить текущего пользователя."""
|
||||
return {"user_id": user["id"], "role": user["role"]}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Escrow endpoints (гарант)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas.escrow import EscrowCreate, EscrowRelease
|
||||
from app.models.escrow import EscrowTransaction
|
||||
from app.models.project import Project
|
||||
|
||||
router = APIRouter(prefix="/api/escrow", tags=["escrow"])
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_escrow(data: EscrowCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Создать escrow-транзакцию."""
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == data.project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||
|
||||
transaction = EscrowTransaction(
|
||||
project_id=data.project_id,
|
||||
client_id=data.client_id,
|
||||
freelancer_id=data.freelancer_id,
|
||||
amount=data.amount,
|
||||
status="pending",
|
||||
)
|
||||
db.add(transaction)
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
return {
|
||||
"id": str(transaction.id),
|
||||
"status": transaction.status,
|
||||
"amount": float(transaction.amount),
|
||||
"payment_url": f"https://stripe.com/pay/{transaction.id}", # Stripe redirect
|
||||
}
|
||||
|
||||
|
||||
@router.post("/release")
|
||||
async def release_escrow(data: EscrowRelease, db: AsyncSession = Depends(get_db)):
|
||||
"""Освободить средства фрилансеру."""
|
||||
|
||||
result = await db.execute(select(EscrowTransaction).where(EscrowTransaction.id == data.transaction_id))
|
||||
transaction = result.scalar_one_or_none()
|
||||
|
||||
if not transaction or transaction.status != "locked":
|
||||
raise HTTPException(status_code=400, detail="Транзакция не может быть разблокирована")
|
||||
|
||||
# Комиссия платформы 10%
|
||||
commission = transaction.amount * 0.10
|
||||
freelancer_amount = transaction.amount - commission
|
||||
|
||||
transaction.status = "released"
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"id": str(transaction.id),
|
||||
"status": "released",
|
||||
"freelancer_payout": float(freelancer_amount),
|
||||
"platform_commission": float(commission),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/dispute")
|
||||
async def dispute_escrow(transaction_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Открыть спор по escrow."""
|
||||
|
||||
result = await db.execute(select(EscrowTransaction).where(EscrowTransaction.id == transaction_id))
|
||||
transaction = result.scalar_one_or_none()
|
||||
|
||||
if not transaction:
|
||||
raise HTTPException(status_code=404, detail="Транзакция не найдена")
|
||||
|
||||
transaction.status = "disputed"
|
||||
await db.commit()
|
||||
|
||||
return {"status": "dispute_opened", "transaction_id": str(transaction.id)}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Endpoints для Milestone-платежей (Upwork-style)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.project import Project
|
||||
from app.models.milestone import Milestone
|
||||
from app.models.escrow import EscrowTransaction
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_id}/milestones", tags=["milestones"])
|
||||
|
||||
|
||||
@router.post("", response_model=dict)
|
||||
async def create_milestone(
|
||||
project_id: str, data: dict, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Создать milestone для проекта."""
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project or project.client_id != user["id"]:
|
||||
raise HTTPException(status_code=403, detail="Только владелец проекта может создавать milestones")
|
||||
|
||||
milestone = Milestone(
|
||||
project_id=project_id,
|
||||
title=data.get("title", ""),
|
||||
description=data.get("description"),
|
||||
amount=float(data.get("amount")),
|
||||
due_date=None, # ISO format string
|
||||
)
|
||||
db.add(milestone)
|
||||
await db.commit()
|
||||
await db.refresh(milestone)
|
||||
|
||||
return {"id": str(milestone.id), "status": milestone.status}
|
||||
|
||||
|
||||
@router.patch("/{milestone_id}/submit")
|
||||
async def submit_milestone(milestone_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Фрилансер завершает milestone."""
|
||||
|
||||
result = await db.execute(select(Milestone).where(Milestone.id == milestone_id))
|
||||
milestone = result.scalar_one_or_none()
|
||||
|
||||
if not milestone or milestone.status != "funded":
|
||||
raise HTTPException(status_code=400, detail="Milestone не может быть завершён")
|
||||
|
||||
milestone.status = "submitted"
|
||||
await db.commit()
|
||||
|
||||
return {"status": "submitted", "milestone_id": str(milestone.id)}
|
||||
|
||||
|
||||
@router.patch("/{milestone_id}/approve")
|
||||
async def approve_milestone(milestone_id: str, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
"""Клиент одобряет milestone."""
|
||||
|
||||
result = await db.execute(select(Milestone).where(Milestone.id == milestone_id))
|
||||
milestone = result.scalar_one_or_none()
|
||||
|
||||
if not milestone or milestone.status != "submitted":
|
||||
raise HTTPException(status_code=400, detail="Milestone не может быть одобрен")
|
||||
|
||||
milestone.status = "approved"
|
||||
await db.commit()
|
||||
|
||||
return {"status": "approved", "milestone_id": str(milestone.id)}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Endpoints для портфолио фрилансера."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.portfolio import PortfolioItem
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/portfolio", tags=["portfolio"])
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_portfolio_item(data: dict, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||||
"""Добавить работу в портфолио."""
|
||||
|
||||
item = PortfolioItem(
|
||||
freelancer_id=user["id"],
|
||||
title=data.get("title", ""),
|
||||
description=data.get("description"),
|
||||
image_url=data.get("image_url"),
|
||||
live_url=data.get("live_url"),
|
||||
technologies=data.get("technologies", []),
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return {"id": str(item.id)}
|
||||
|
||||
|
||||
@router.get("/user/{user_id}")
|
||||
async def list_portfolio(user_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Список работ в портфолио пользователя."""
|
||||
|
||||
result = await db.execute(select(PortfolioItem).where(PortfolioItem.freelancer_id == user_id))
|
||||
items = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(i.id),
|
||||
"title": i.title,
|
||||
"description": i.description,
|
||||
"image_url": i.image_url,
|
||||
"live_url": i.live_url,
|
||||
"technologies": i.technologies,
|
||||
"created_at": str(i.created_at),
|
||||
} for i in items
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{item_id}")
|
||||
async def delete_portfolio_item(item_id: str, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
"""Удалить работу из портфолио."""
|
||||
|
||||
result = await db.execute(select(PortfolioItem).where(PortfolioItem.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
|
||||
if not item or item.freelancer_id != user["id"]:
|
||||
raise HTTPException(status_code=403, detail="Нет доступа")
|
||||
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Endpoints для проектов."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
||||
from app.models.user import User
|
||||
from app.models.project import Project
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectResponse)
|
||||
async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||||
"""Создать новый проект."""
|
||||
|
||||
project = Project(
|
||||
client_id=user["id"],
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
category=data.category,
|
||||
required_skills=data.required_skills,
|
||||
budget_min=data.budget_min,
|
||||
budget_max=data.budget_max,
|
||||
deadline=data.deadline,
|
||||
)
|
||||
db.add(project)
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
|
||||
return ProjectResponse(
|
||||
id=str(project.id),
|
||||
title=project.title,
|
||||
description=project.description,
|
||||
category=project.category,
|
||||
required_skills=project.required_skills,
|
||||
budget_min=float(project.budget_min) if project.budget_min else None,
|
||||
budget_max=float(project.budget_max) if project.budget_max else None,
|
||||
status=project.status,
|
||||
deadline=str(project.deadline) if project.deadline else None,
|
||||
created_at=str(project.created_at),
|
||||
updated_at=str(project.updated_at),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProjectResponse])
|
||||
async def list_projects(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
category: str | None = None,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
):
|
||||
"""Список проектов с фильтрацией."""
|
||||
|
||||
stmt = select(Project)
|
||||
|
||||
if status_filter:
|
||||
stmt = stmt.where(Project.status == status_filter)
|
||||
if category:
|
||||
stmt = stmt.where(Project.category == category)
|
||||
|
||||
stmt = stmt.order_by(desc(Project.created_at)).offset((page - 1) * limit).limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
projects = result.scalars().all()
|
||||
|
||||
return [ProjectResponse(
|
||||
id=str(p.id), title=p.title, description=p.description, category=p.category,
|
||||
required_skills=p.required_skills, budget_min=float(p.budget_min) if p.budget_min else None,
|
||||
budget_max=float(p.budget_max) if p.budget_max else None, status=p.status,
|
||||
deadline=str(p.deadline) if p.deadline else None, created_at=str(p.created_at), updated_at=str(p.updated_at),
|
||||
) for p in projects]
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
async def get_project(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Получить проект по ID."""
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||
|
||||
return ProjectResponse(
|
||||
id=str(project.id), title=project.title, description=project.description, category=project.category,
|
||||
required_skills=project.required_skills, budget_min=float(project.budget_min) if project.budget_min else None,
|
||||
budget_max=float(project.budget_max) if project.budget_max else None, status=project.status,
|
||||
deadline=str(project.deadline) if project.deadline else None, created_at=str(project.created_at), updated_at=str(project.updated_at),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectResponse)
|
||||
async def update_project(project_id: str, data: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
||||
"""Обновить проект."""
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(project, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
|
||||
return ProjectResponse(
|
||||
id=str(project.id), title=project.title, description=project.description, category=project.category,
|
||||
required_skills=project.required_skills, budget_min=float(project.budget_min) if project.budget_min else None,
|
||||
budget_max=float(project.budget_max) if project.budget_max else None, status=project.status,
|
||||
deadline=str(project.deadline) if project.deadline else None, created_at=str(project.created_at), updated_at=str(project.updated_at),
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Endpoints для заявок фрилансеров."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.schemas.proposal import ProposalCreate, ProposalResponse
|
||||
from app.models.project import Project
|
||||
from app.models.proposal import Proposal
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_id}/proposals", tags=["proposals"])
|
||||
|
||||
|
||||
@router.post("", response_model=ProposalResponse)
|
||||
async def create_proposal(
|
||||
project_id: str, data: ProposalCreate, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Фрилансер подаёт заявку на проект."""
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||
|
||||
proposal = Proposal(
|
||||
project_id=project_id,
|
||||
freelancer_id=user["id"],
|
||||
cover_letter=data.cover_letter,
|
||||
proposed_price=data.proposed_price,
|
||||
estimated_days=data.estimated_days,
|
||||
)
|
||||
db.add(proposal)
|
||||
await db.commit()
|
||||
await db.refresh(proposal)
|
||||
|
||||
return ProposalResponse(
|
||||
id=str(proposal.id), project_id=project_id, freelancer_id=user["id"],
|
||||
cover_letter=proposal.cover_letter, proposed_price=float(proposal.proposed_price) if proposal.proposed_price else None,
|
||||
estimated_days=proposal.estimated_days, status=proposal.status, created_at=str(proposal.created_at),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProposalResponse])
|
||||
async def list_proposals(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Список заявок на проект."""
|
||||
|
||||
result = await db.execute(select(Proposal).where(Proposal.project_id == project_id).order_by(desc(Proposal.created_at)))
|
||||
proposals = result.scalars().all()
|
||||
|
||||
return [ProposalResponse(
|
||||
id=str(p.id), project_id=p.project_id, freelancer_id=p.freelancer_id, cover_letter=p.cover_letter,
|
||||
proposed_price=float(p.proposed_price) if p.proposed_price else None, estimated_days=p.estimated_days,
|
||||
status=p.status, created_at=str(p.created_at),
|
||||
) for p in proposals]
|
||||
|
||||
|
||||
@router.patch("/{proposal_id}/status")
|
||||
async def update_proposal_status(proposal_id: str, status: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Обновить статус заявки (accept/reject)."""
|
||||
|
||||
result = await db.execute(select(Proposal).where(Proposal.id == proposal_id))
|
||||
proposal = result.scalar_one_or_none()
|
||||
|
||||
if not proposal:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
|
||||
proposal.status = status
|
||||
await db.commit()
|
||||
|
||||
return {"status": "updated", "proposal_id": str(proposal.id)}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Endpoints для отзывов и рейтингов."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.schemas.review import ReviewCreate, ReviewResponse
|
||||
from app.models.project import Project
|
||||
from app.models.review import Review
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/reviews", tags=["reviews"])
|
||||
|
||||
|
||||
@router.post("", response_model=ReviewResponse)
|
||||
async def create_review(data: ReviewCreate, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||||
"""Оставить отзыв на проект."""
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == data.project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||
|
||||
# Проверка что пользователь участвовал в проекте
|
||||
if user["id"] != project.client_id and user["id"] != project.proposals[0].freelancer_id if project.proposals else True:
|
||||
raise HTTPException(status_code=403, detail="Только участники проекта могут оставить отзыв")
|
||||
|
||||
review = Review(
|
||||
project_id=data.project_id,
|
||||
reviewer_id=user["id"],
|
||||
reviewee_id=data.reviewee_id,
|
||||
rating=data.rating,
|
||||
comment=data.comment,
|
||||
)
|
||||
db.add(review)
|
||||
await db.commit()
|
||||
await db.refresh(review)
|
||||
|
||||
# Обновляем рейтинг пользователя
|
||||
result = await db.execute(select(func.avg(Review.rating)).where(Review.reviewee_id == data.reviewee_id))
|
||||
avg_rating = float(result.scalar_one_or_none()) or 0.0
|
||||
|
||||
return ReviewResponse(
|
||||
id=str(review.id),
|
||||
project_id=review.project_id,
|
||||
reviewer_name="Аноним",
|
||||
reviewee_name="Аноним",
|
||||
rating=review.rating,
|
||||
comment=review.comment,
|
||||
created_at=str(review.created_at),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/project/{project_id}", response_model=list[ReviewResponse])
|
||||
async def list_reviews(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Список отзывов по проекту."""
|
||||
|
||||
result = await db.execute(select(Review).where(Review.project_id == project_id))
|
||||
reviews = result.scalars().all()
|
||||
|
||||
return [ReviewResponse(
|
||||
id=str(r.id), project_id=r.project_id, reviewer_name="Аноним", reviewee_name="Аноним",
|
||||
rating=r.rating, comment=r.comment, created_at=str(r.created_at)
|
||||
) for r in reviews]
|
||||
|
||||
|
||||
@router.get("/user/{user_id}", response_model=dict)
|
||||
async def get_user_rating(user_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Получить рейтинг пользователя."""
|
||||
|
||||
result = await db.execute(select(func.avg(Review.rating)).where(Review.reviewee_id == user_id))
|
||||
avg_rating = float(result.scalar_one_or_none()) or 0.0
|
||||
|
||||
result2 = await db.execute(select(func.count(Review.id)).where(Review.reviewee_id == user_id))
|
||||
total_reviews = int(result2.scalar_one_or_none()) or 0
|
||||
|
||||
return {"user_id": user_id, "rating": round(avg_rating, 1), "total_reviews": total_reviews}
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Endpoints для Skill Tests (сертификация навыков)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.skill_test import SkillTest, SkillTestResult
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/skill-tests", tags=["skill-tests"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tests(db: AsyncSession = Depends(get_db)):
|
||||
"""Список доступных тестов навыков."""
|
||||
|
||||
result = await db.execute(select(SkillTest))
|
||||
tests = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(t.id),
|
||||
"name": t.name,
|
||||
"category": t.category,
|
||||
"questions_count": t.questions_count,
|
||||
"passing_score": t.passing_score,
|
||||
} for t in tests
|
||||
]
|
||||
|
||||
|
||||
@router.post("/take/{test_id}")
|
||||
async def take_test(test_id: str, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
"""Начать тест навыков."""
|
||||
|
||||
result = await db.execute(select(SkillTest).where(SkillTest.id == test_id))
|
||||
test = result.scalar_one_or_none()
|
||||
|
||||
if not test:
|
||||
raise HTTPException(status_code=404, detail="Тест не найден")
|
||||
|
||||
# Генерация вопросов (placeholder — в продакшене реальные вопросы)
|
||||
questions = [
|
||||
{"id": i + 1, "question": f"Вопрос {i+1} по теме '{test.name}'", "options": ["A", "B", "C", "D"], "correct": 0}
|
||||
for i in range(test.questions_count)
|
||||
]
|
||||
|
||||
return {"test_id": str(test.id), "questions": questions, "time_limit_minutes": test.questions_count * 2}
|
||||
|
||||
|
||||
@router.post("/submit/{test_id}")
|
||||
async def submit_test(test_id: str, answers: dict, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
"""Отправить ответы на тест."""
|
||||
|
||||
result = await db.execute(select(SkillTest).where(SkillTest.id == test_id))
|
||||
test = result.scalar_one_or_none()
|
||||
|
||||
if not test:
|
||||
raise HTTPException(status_code=404, detail="Тест не найден")
|
||||
|
||||
# Подсчёт баллов (placeholder)
|
||||
score = 75.0 # В продакшене реальный подсчёт
|
||||
|
||||
passed = score >= test.passing_score
|
||||
|
||||
result2 = await db.execute(select(SkillTestResult).where(
|
||||
SkillTestResult.user_id == user["id"],
|
||||
SkillTestResult.skill_test_id == test_id,
|
||||
))
|
||||
existing = result2.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
existing.score = score
|
||||
existing.passed = passed
|
||||
existing.completed_at = None # TODO: datetime.now(timezone.utc)
|
||||
else:
|
||||
new_result = SkillTestResult(
|
||||
user_id=user["id"],
|
||||
skill_test_id=test_id,
|
||||
score=score,
|
||||
passed=passed,
|
||||
completed_at=None,
|
||||
)
|
||||
db.add(new_result)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"score": score, "passed": passed}
|
||||
|
||||
|
||||
@router.get("/user/{user_id}")
|
||||
async def get_user_tests(user_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Получить результаты тестов пользователя."""
|
||||
|
||||
result = await db.execute(select(SkillTestResult).where(SkillTestResult.user_id == user_id))
|
||||
results = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"test_name": r.skill_test.name if hasattr(r, 'skill_test') else "Unknown",
|
||||
"score": r.score,
|
||||
"passed": r.passed,
|
||||
"completed_at": str(r.completed_at) if r.completed_at else None,
|
||||
} for r in results
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Endpoints для верификации профиля."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.verification import Verification
|
||||
|
||||
router = APIRouter(prefix="/api/verification", tags=["verification"])
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me_verification(user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
"""Получить статус верификации текущего пользователя."""
|
||||
|
||||
result = await db.execute(select(Verification).where(Verification.user_id == user["id"]))
|
||||
verification = result.scalar_one_or_none()
|
||||
|
||||
if not verification:
|
||||
return {
|
||||
"user_id": user["id"],
|
||||
"is_email_verified": False,
|
||||
"is_phone_verified": False,
|
||||
"is_id_verified": False,
|
||||
"is_bank_verified": False,
|
||||
"verified_at": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"user_id": verification.user_id,
|
||||
"is_email_verified": verification.is_email_verified,
|
||||
"is_phone_verified": verification.is_phone_verified,
|
||||
"is_id_verified": verification.is_id_verified,
|
||||
"is_bank_verified": verification.is_bank_verified,
|
||||
"verified_at": str(verification.verified_at) if verification.verified_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/email/verify")
|
||||
async def verify_email(user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
"""Подтвердить email."""
|
||||
|
||||
result = await db.execute(select(Verification).where(Verification.user_id == user["id"]))
|
||||
verification = result.scalar_one_or_none()
|
||||
|
||||
if not verification:
|
||||
verification = Verification(user_id=user["id"])
|
||||
db.add(verification)
|
||||
|
||||
verification.is_email_verified = True
|
||||
await db.commit()
|
||||
|
||||
return {"status": "email_verified"}
|
||||
|
||||
|
||||
@router.post("/phone/verify")
|
||||
async def verify_phone(user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
"""Подтвердить телефон."""
|
||||
|
||||
result = await db.execute(select(Verification).where(Verification.user_id == user["id"]))
|
||||
verification = result.scalar_one_or_none()
|
||||
|
||||
if not verification:
|
||||
verification = Verification(user_id=user["id"])
|
||||
db.add(verification)
|
||||
|
||||
verification.is_phone_verified = True
|
||||
await db.commit()
|
||||
|
||||
return {"status": "phone_verified"}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Конфигурация приложения."""
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# БД
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# OpenAI (для AI-матчинга)
|
||||
OPENAI_API_KEY: str = ""
|
||||
|
||||
# OAuth
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GITHUB_CLIENT_ID: str = ""
|
||||
GITHUB_CLIENT_SECRET: str = ""
|
||||
|
||||
# Stripe (Escrow)
|
||||
STRIPE_SECRET_KEY: str = ""
|
||||
STRIPE_WEBHOOK_SECRET: str = ""
|
||||
|
||||
# Email
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
EMAIL_FROM: str = "noreply@freelancermatch.com"
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: list[str] = ["http://localhost:3000", "https://freelancermatch.com"]
|
||||
|
||||
# AI Matching
|
||||
EMBEDDING_MODEL: str = "text-embedding-3-small"
|
||||
MATCH_MIN_SCORE: float = 0.7
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Подключение к PostgreSQL и Redis."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Зависимость для получения сессии БД."""
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Безопасность: JWT, хеширование паролей."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def create_refresh_token(data: dict[str, Any]) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
data_copy = data.copy()
|
||||
data_copy.update({"exp": expire, "type": "refresh"})
|
||||
return jwt.encode(data_copy, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict[str, Any]:
|
||||
"""Извлечь текущего пользователя из токена."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
user_id = payload.get("sub")
|
||||
role = payload.get("role")
|
||||
if not user_id or not role:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return {"id": user_id, "role": role}
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""FastAPI приложение — Freelancer Match."""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.projects import router as projects_router
|
||||
from app.api.proposals import router as proposals_router
|
||||
from app.api.ai import router as ai_router
|
||||
from app.api.escrow import router as escrow_router
|
||||
from app.api.reviews import router as reviews_router
|
||||
from app.api.milestones import router as milestones_router
|
||||
from app.api.portfolio import router as portfolio_router
|
||||
from app.api.skill_tests import router as skill_tests_router
|
||||
from app.api.verification import router as verification_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Запуск и остановка приложения."""
|
||||
logging.info("🚀 Freelancer Match starting...")
|
||||
yield
|
||||
logging.info("🛑 Freelancer Match shutting down.")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Freelancer Match API",
|
||||
description="Площадка для фрилансеров с AI-матчингом и escrow-гарантом",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Routes
|
||||
app.include_router(auth_router)
|
||||
app.include_router(projects_router)
|
||||
app.include_router(proposals_router)
|
||||
app.include_router(ai_router)
|
||||
app.include_router(escrow_router)
|
||||
app.include_router(reviews_router)
|
||||
app.include_router(milestones_router)
|
||||
app.include_router(portfolio_router)
|
||||
app.include_router(skill_tests_router)
|
||||
app.include_router(verification_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "freelancer-match"}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Модели SQLAlchemy."""
|
||||
|
||||
from app.models.user import User, FreelancerProfile, ClientProfile
|
||||
from app.models.project import Project
|
||||
from app.models.proposal import Proposal
|
||||
from app.models.ai_match import AIMatch
|
||||
from app.models.escrow import EscrowTransaction
|
||||
from app.models.milestone import Milestone
|
||||
from app.models.work_session import WorkSession
|
||||
from app.models.review import Review
|
||||
from app.models.message import Message
|
||||
from app.models.notification import Notification
|
||||
from app.models.portfolio import PortfolioItem
|
||||
from app.models.skill_test import SkillTest, SkillTestResult
|
||||
from app.models.verification import Verification
|
||||
|
||||
__all__ = [
|
||||
"User", "FreelancerProfile", "ClientProfile",
|
||||
"Project", "Proposal", "AIMatch",
|
||||
"EscrowTransaction", "Milestone", "WorkSession",
|
||||
"Review", "Message", "Notification",
|
||||
"PortfolioItem", "SkillTest", "SkillTestResult",
|
||||
"Verification",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Модель AI-рекомендаций."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Float, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AIMatch(Base):
|
||||
__tablename__ = "ai_matches"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
match_score: Mapped[float] = mapped_column(Float(precision=5, scale=4), nullable=False)
|
||||
reasons: Mapped[list] = mapped_column("reasons", postgresql.JSONB, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Модель Escrow-транзакций (гарант)."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class EscrowTransaction(Base):
|
||||
__tablename__ = "escrow_transactions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
amount: Mapped[float] = mapped_column(Float(precision=10, scale=2), nullable=False)
|
||||
status: Mapped[str] = mapped_column(Enum("pending", "locked", "released", "disputed", "refunded"), default="pending")
|
||||
released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Модель сообщений чата."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
work_session_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("work_sessions.id"), nullable=False)
|
||||
sender_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
attachments: Mapped[list | None] = mapped_column("attachments", postgresql.JSONB, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Модель Milestone-платежей (Upwork-style)."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Milestone(Base):
|
||||
__tablename__ = "milestones"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
escrow_transaction_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("escrow_transactions.id"))
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
amount: Mapped[float] = mapped_column(Float(precision=10, scale=2), nullable=False)
|
||||
status: Mapped[str] = mapped_column(Enum("pending", "funded", "in_progress", "submitted", "approved", "disputed"), default="pending")
|
||||
due_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Модель уведомлений."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Boolean, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(50)) # proposal_received, payment_released, etc.
|
||||
title: Mapped[str | None] = mapped_column(String(255))
|
||||
body: Mapped[str | None] = mapped_column(Text)
|
||||
is_read: Mapped[bool] = mapped_column(default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Модель портфолио фрилансера."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PortfolioItem(Base):
|
||||
__tablename__ = "portfolio_items"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
image_url: Mapped[str | None] = mapped_column(Text) # URL превью работы
|
||||
live_url: Mapped[str | None] = mapped_column(Text) # Ссылка на работу
|
||||
technologies: Mapped[list] = mapped_column("technologies", postgresql.JSONB, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Модели проектов и заявок."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
category: Mapped[str | None] = mapped_column(String(100))
|
||||
required_skills: Mapped[list] = mapped_column("required_skills", postgresql.JSONB, default=list)
|
||||
skill_embeddings: Mapped[list | None] = mapped_column("skill_embeddings") # pgvector
|
||||
budget_min: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||
budget_max: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||
status: Mapped[str] = mapped_column(Enum("open", "in_progress", "completed", "cancelled"), default="open")
|
||||
deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
client = relationship("User", foreign_keys=[client_id])
|
||||
proposals: Mapped[list["Proposal"]] = relationship(back_populates="project")
|
||||
|
||||
|
||||
class Proposal(Base):
|
||||
__tablename__ = "proposals"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
cover_letter: Mapped[str | None] = mapped_column(Text)
|
||||
proposed_price: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||
estimated_days: Mapped[int | None] = mapped_column(Integer)
|
||||
status: Mapped[str] = mapped_column(Enum("pending", "accepted", "rejected"), default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
project: Mapped["Project"] = relationship(back_populates="proposals")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Модель отзывов."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Review(Base):
|
||||
__tablename__ = "reviews"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
reviewer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
reviewee_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
rating: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
comment: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Модель Skill Tests (сертификация навыков как на Upwork)."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SkillTest(Base):
|
||||
__tablename__ = "skill_tests"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False) # например "Python Basics"
|
||||
category: Mapped[str] = mapped_column(String(100)) # programming, design, etc.
|
||||
questions_count: Mapped[int] = mapped_column(Integer, default=40)
|
||||
passing_score: Mapped[float] = mapped_column(Float(precision=5, scale=2), default=70.0)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class SkillTestResult(Base):
|
||||
__tablename__ = "skill_test_results"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
skill_test_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("skill_tests.id"), nullable=False)
|
||||
score: Mapped[float] = mapped_column(Float(precision=5, scale=2))
|
||||
passed: Mapped[bool] = mapped_column(default=False)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Модели пользователей."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, Float, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
full_name: Mapped[str | None] = mapped_column(String(100))
|
||||
avatar_url: Mapped[str | None] = mapped_column(Text)
|
||||
role: Mapped[str] = mapped_column(Enum("client", "freelancer", "both"), nullable=False, default="freelancer")
|
||||
is_verified: Mapped[bool] = mapped_column(default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
freelancer_profile: Mapped["FreelancerProfile | None"] = relationship(back_populates="user", uselist=False)
|
||||
client_profile: Mapped["ClientProfile | None"] = relationship(back_populates="user", uselist=False)
|
||||
|
||||
|
||||
class FreelancerProfile(Base):
|
||||
__tablename__ = "freelancer_profiles"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, nullable=False)
|
||||
bio: Mapped[str | None] = mapped_column(Text)
|
||||
skills: Mapped[list] = mapped_column("skills", postgresql.JSONB, default=list)
|
||||
skill_embeddings: Mapped[list | None] = mapped_column("skill_embeddings") # pgvector VECTOR(1536)
|
||||
hourly_rate: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||
portfolio_items: Mapped[list] = mapped_column("portfolio_items", postgresql.JSONB, default=list)
|
||||
experience_years: Mapped[int | None] = mapped_column(Integer)
|
||||
languages: Mapped[list] = mapped_column("languages", postgresql.JSONB, default=list)
|
||||
rating: Mapped[float] = mapped_column(Float(precision=3, scale=2), default=5.0)
|
||||
total_jobs_completed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
response_time_hours: Mapped[int | None] = mapped_column(Integer)
|
||||
is_online: Mapped[bool] = mapped_column(default=False)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="freelancer_profile")
|
||||
|
||||
|
||||
class ClientProfile(Base):
|
||||
__tablename__ = "client_profiles"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, nullable=False)
|
||||
company_name: Mapped[str | None] = mapped_column(String(255))
|
||||
industry: Mapped[str | None] = mapped_column(String(100))
|
||||
budget_range: Mapped[dict | None] = mapped_column("budget_range", postgresql.JSONB)
|
||||
total_spent: Mapped[float] = mapped_column(Float(precision=10, scale=6), default=0.0)
|
||||
rating: Mapped[float] = mapped_column(Float(precision=3, scale=2), default=5.0)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="client_profile")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Модель верификации профиля (Verified Badges)."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Boolean, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Verification(Base):
|
||||
__tablename__ = "verifications"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False, unique=True)
|
||||
|
||||
# Типы верификации
|
||||
is_email_verified: Mapped[bool] = mapped_column(default=False)
|
||||
is_phone_verified: Mapped[bool] = mapped_column(default=False)
|
||||
is_id_verified: Mapped[bool] = mapped_column(default=False) # ID document
|
||||
is_bank_verified: Mapped[bool] = mapped_column(default=False) # Bank account
|
||||
|
||||
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Модель рабочей сессии."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class WorkSession(Base):
|
||||
__tablename__ = "work_sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
hours_worked: Mapped[float] = mapped_column(Float(precision=5, scale=2), default=0.0)
|
||||
status: Mapped[str] = mapped_column(Enum("active", "paused", "completed"), default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Pydantic схемы для валидации запросов/ответов."""
|
||||
|
||||
from app.schemas.user import UserCreate, UserLogin, UserProfileUpdate, FreelancerProfileCreate
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
||||
from app.schemas.proposal import ProposalCreate, ProposalResponse
|
||||
from app.schemas.ai_match import AIMatchRequest, AIMatchResponse
|
||||
from app.schemas.escrow import EscrowCreate, EscrowRelease
|
||||
from app.schemas.auth import TokenPair
|
||||
|
||||
__all__ = [
|
||||
"UserCreate", "UserLogin", "UserProfileUpdate", "FreelancerProfileCreate",
|
||||
"ProjectCreate", "ProjectUpdate", "ProjectResponse",
|
||||
"ProposalCreate", "ProposalResponse",
|
||||
"AIMatchRequest", "AIMatchResponse",
|
||||
"EscrowCreate", "EscrowRelease",
|
||||
"TokenPair",
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Схемы AI-матчинга."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AIMatchRequest(BaseModel):
|
||||
project_id: str = Field(..., description="ID проекта")
|
||||
limit: int = Field(default=10, ge=1, le=50)
|
||||
min_score: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class AIMatchResponse(BaseModel):
|
||||
freelancer_id: str
|
||||
name: str
|
||||
skills_matched: list[str]
|
||||
match_score: float
|
||||
reasons: list[str]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Схемы авторизации."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TokenPair(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Схемы escrow-транзакций."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EscrowCreate(BaseModel):
|
||||
project_id: str = Field(..., description="ID проекта")
|
||||
client_id: str = Field(..., description="ID клиента")
|
||||
freelancer_id: str = Field(..., description="ID фрилансера")
|
||||
amount: float = Field(..., gt=0, description="Сумма в рублях")
|
||||
|
||||
|
||||
class EscrowRelease(BaseModel):
|
||||
transaction_id: str = Field(..., description="ID транзакции")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Схемы проектов."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
title: str = Field(..., min_length=5, max_length=255)
|
||||
description: str = Field(..., min_length=20)
|
||||
category: str | None = None
|
||||
required_skills: list[str] = []
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
deadline: str | None = None # ISO format
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
deadline: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
category: str | None
|
||||
required_skills: list[str]
|
||||
budget_min: float | None
|
||||
budget_max: float | None
|
||||
status: str
|
||||
deadline: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Схемы заявок."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProposalCreate(BaseModel):
|
||||
cover_letter: str | None = None
|
||||
proposed_price: float | None = None
|
||||
estimated_days: int | None = None
|
||||
|
||||
|
||||
class ProposalResponse(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
freelancer_id: str
|
||||
cover_letter: str | None
|
||||
proposed_price: float | None
|
||||
estimated_days: int | None
|
||||
status: str
|
||||
created_at: str
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Схемы отзывов и рейтингов."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ReviewCreate(BaseModel):
|
||||
project_id: str = Field(..., description="ID проекта")
|
||||
reviewee_id: str = Field(..., description="ID того кого оценивают")
|
||||
rating: int = Field(..., ge=1, le=5)
|
||||
comment: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class ReviewResponse(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
reviewer_name: str
|
||||
reviewee_name: str
|
||||
rating: int
|
||||
comment: str | None
|
||||
created_at: str
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Схемы пользователей."""
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str # min 12 chars
|
||||
role: str = "freelancer" # client | freelancer | both
|
||||
full_name: str | None = None
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class FreelancerProfileCreate(BaseModel):
|
||||
bio: str | None = None
|
||||
skills: list[str] = []
|
||||
hourly_rate: float | None = None
|
||||
experience_years: int | None = None
|
||||
languages: list[str] = []
|
||||
@@ -0,0 +1,150 @@
|
||||
"""AI-сервис для матчинга фрилансеров и проектов."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import openai
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.project import Project
|
||||
from app.models.user import FreelancerProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_embedding(text: str) -> list[float]:
|
||||
"""Получить эмбеддинг через OpenAI."""
|
||||
client = openai.OpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
response = await client.embeddings.create(
|
||||
model=settings.EMBEDDING_MODEL,
|
||||
input=text,
|
||||
)
|
||||
return response.data[0].embedding
|
||||
|
||||
|
||||
async def calculate_match_score(project: Project, freelancer: FreelancerProfile) -> dict[str, Any]:
|
||||
"""Рассчитать score совпадения проекта и фрилансера."""
|
||||
|
||||
# 1. Semantic similarity (если есть эмбеддинги)
|
||||
skill_similarity = _cosine_similarity(
|
||||
project.skill_embeddings or [],
|
||||
freelancer.skill_embeddings or []
|
||||
) if project.skill_embeddings and freelancer.skill_embeddings else 0.5
|
||||
|
||||
# 2. Опыт
|
||||
exp_score = min(freelancer.experience_years / max(project.required_skills.__len__() * 3, 3), 1.0) \
|
||||
if freelancer.experience_years else 0.5
|
||||
|
||||
# 3. Рейтинг
|
||||
rating_weight = (freelancer.rating - 4.0) * 0.5 + 0.5
|
||||
|
||||
# 4. Время ответа
|
||||
response_score = max(0, 1 - (freelancer.response_time_hours or 24) / 24)
|
||||
|
||||
# 5. Совпадение навыков
|
||||
skills_match = len(set(project.required_skills) & set(freelancer.skills)) / \
|
||||
max(len(project.required_skills), 1) if project.required_skills else 0
|
||||
|
||||
# Взвешенная сумма
|
||||
score = (
|
||||
skill_similarity * 0.35 +
|
||||
exp_score * 0.20 +
|
||||
rating_weight * 0.15 +
|
||||
response_score * 0.10 +
|
||||
skills_match * 0.20
|
||||
)
|
||||
|
||||
# Генерация причин через LLM
|
||||
reasons = await _generate_reasons(project, freelancer)
|
||||
|
||||
return {
|
||||
"match_score": round(score, 4),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
async def find_matches(
|
||||
db: AsyncSession, project_id: str, limit: int = 10, min_score: float | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Найти лучших фрилансеров для проекта."""
|
||||
|
||||
# Получаем проект
|
||||
from app.models.ai_match import AIMatch
|
||||
|
||||
# Проверяем кэш в Redis
|
||||
import redis.asyncio as aioredis
|
||||
r = aioredis.from_url(settings.REDIS_URL)
|
||||
cache_key = f"ai_matches:{project_id}"
|
||||
cached = await r.get(cache_key)
|
||||
|
||||
if cached:
|
||||
return __import__("json").loads(cached)
|
||||
|
||||
# Запрос к БД (упрощённый — в продакшене нужен pgvector query)
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(FreelancerProfile).order_by(
|
||||
FreelancerProfile.rating.desc()
|
||||
).limit(limit * 2)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
freelancers = result.scalars().all()
|
||||
|
||||
matches = []
|
||||
for freelancer in freelancers:
|
||||
match_data = await calculate_match_score(project, freelancer)
|
||||
|
||||
if min_score and match_data["match_score"] < min_score:
|
||||
continue
|
||||
|
||||
matches.append({
|
||||
"freelancer_id": str(freelancer.user_id),
|
||||
"name": freelancer.user.full_name or "Аноним",
|
||||
"skills_matched": list(set(project.required_skills) & set(freelancer.skills)),
|
||||
**match_data,
|
||||
})
|
||||
|
||||
matches.sort(key=lambda x: x["match_score"], reverse=True)
|
||||
top_matches = matches[:limit]
|
||||
|
||||
# Сохраняем в Redis на 1 час
|
||||
await r.setex(cache_key, 3600, __import__("json").dumps(top_matches))
|
||||
|
||||
return top_matches
|
||||
|
||||
|
||||
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
"""Косинусное сходство двух векторов."""
|
||||
if not a or not b:
|
||||
return 0.5
|
||||
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = (sum(x ** 2 for x in a) ** 0.5)
|
||||
norm_b = (sum(y ** 2 for y in b) ** 0.5)
|
||||
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return 0.5
|
||||
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
|
||||
async def _generate_reasons(project: Project, freelancer: FreelancerProfile) -> list[str]:
|
||||
"""Генерация причин совпадения через LLM."""
|
||||
|
||||
reasons = []
|
||||
|
||||
if freelancer.experience_years and freelancer.experience_years >= 3:
|
||||
reasons.append(f"Опыт {freelancer.experience_years} лет в разработке")
|
||||
|
||||
matched_skills = set(project.required_skills) & set(freelancer.skills)
|
||||
if matched_skills:
|
||||
reasons.append(f"Совпадение навыков: {', '.join(list(matched_skills)[:3])}")
|
||||
|
||||
if freelancer.rating >= 4.5:
|
||||
reasons.append("Высокий рейтинг")
|
||||
|
||||
if not reasons:
|
||||
reasons.append("Профиль соответствует требованиям проекта")
|
||||
|
||||
return reasons
|
||||
@@ -0,0 +1,14 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.37
|
||||
alembic==1.14.1
|
||||
asyncpg==0.30.0
|
||||
pydantic-settings==2.7.1
|
||||
pydantic[email]==2.9.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
openai==1.58.1
|
||||
redis[hiredis]==5.2.1
|
||||
celery==5.4.0
|
||||
stripe==11.3.0
|
||||
python-multipart==0.0.18
|
||||
@@ -0,0 +1 @@
|
||||
# Tests for freelancer-match backend
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Конфигурация тестов."""
|
||||
|
||||
import asyncio
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
# Создаём тестовую БД в памяти (SQLite)
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Создаём event loop для тестов."""
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def setup_db():
|
||||
"""Создаём таблицы перед каждым тестом и удаляем после."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Тестовая сессия БД."""
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""HTTP клиент для тестирования API."""
|
||||
from app.main import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_user(db_session):
|
||||
"""Создаём тестового пользователя."""
|
||||
from app.models.user import User
|
||||
|
||||
user = User(email="test@example.com", password_hash="$2b$12$LJ3m4ys6LJ3m4ys6LJ3m4e", role="freelancer")
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_project(db_session, test_user):
|
||||
"""Создаём тестовый проект."""
|
||||
from app.models.project import Project
|
||||
|
||||
project = Project(
|
||||
client_id=test_user.id,
|
||||
title="Тестовый проект",
|
||||
description="Описание проекта для тестирования",
|
||||
category="web-development",
|
||||
required_skills=["python", "fastapi"],
|
||||
budget_min=1000.0,
|
||||
budget_max=5000.0,
|
||||
)
|
||||
db_session.add(project)
|
||||
await db_session.commit()
|
||||
return project
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Тесты для health endpoint."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(client):
|
||||
"""Проверка что health endpoint возвращает ok."""
|
||||
response = await client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["service"] == "freelancer-match"
|
||||
Reference in New Issue
Block a user