1565 lines
58 KiB
Bash
Executable File
1565 lines
58 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
# -------------------------------------------------------------------
|
||
# Apply models + CRUD + API + JWT auth to the existing scaffold
|
||
# Requires: the scaffold created earlier (services/* exist)
|
||
# -------------------------------------------------------------------
|
||
|
||
ROOT_DIR="."
|
||
SERVICES=(auth profiles match chat payments)
|
||
|
||
ensure_line() {
|
||
# ensure_line <file> <literal_line>
|
||
local file="$1" ; shift
|
||
local line="$*"
|
||
grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file"
|
||
}
|
||
|
||
write_file() {
|
||
# write_file <path> <<'EOF' ... EOF
|
||
local path="$1"
|
||
mkdir -p "$(dirname "$path")"
|
||
# The content will be provided by heredoc by the caller
|
||
cat > "$path"
|
||
}
|
||
|
||
append_file() {
|
||
local path="$1"
|
||
mkdir -p "$(dirname "$path")"
|
||
cat >> "$path"
|
||
}
|
||
|
||
require_file() {
|
||
local path="$1"
|
||
if [[ ! -f "$path" ]]; then
|
||
echo "ERROR: Missing $path. Run scaffold.sh first." >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# Basic checks
|
||
require_file docker-compose.yml
|
||
|
||
# -------------------------------------------------------------------
|
||
# 1) .env.example — добавить JWT настройки (общие для всех сервисов)
|
||
# -------------------------------------------------------------------
|
||
ENV_FILE=".env.example"
|
||
require_file "$ENV_FILE"
|
||
|
||
ensure_line "$ENV_FILE" "# ---------- JWT / Auth ----------"
|
||
ensure_line "$ENV_FILE" "JWT_SECRET=devsecret_change_me"
|
||
ensure_line "$ENV_FILE" "JWT_ALGORITHM=HS256"
|
||
ensure_line "$ENV_FILE" "ACCESS_TOKEN_EXPIRES_MIN=15"
|
||
ensure_line "$ENV_FILE" "REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days"
|
||
|
||
# -------------------------------------------------------------------
|
||
# 2) requirements.txt — добавить PyJWT везде, в auth — ещё passlib[bcrypt]
|
||
# -------------------------------------------------------------------
|
||
for s in "${SERVICES[@]}"; do
|
||
REQ="services/$s/requirements.txt"
|
||
require_file "$REQ"
|
||
ensure_line "$REQ" "PyJWT>=2.8"
|
||
if [[ "$s" == "auth" ]]; then
|
||
ensure_line "$REQ" "passlib[bcrypt]>=1.7"
|
||
fi
|
||
done
|
||
|
||
# -------------------------------------------------------------------
|
||
# 3) Общая безопасность (JWT) для всех сервисов
|
||
# В auth добавим + генерацию токенов, в остальных — верификация и RBAC
|
||
# -------------------------------------------------------------------
|
||
for s in "${SERVICES[@]}"; do
|
||
SEC="services/$s/src/app/core/security.py"
|
||
mkdir -p "$(dirname "$SEC")"
|
||
if [[ "$s" == "auth" ]]; then
|
||
write_file "$SEC" <<'PY'
|
||
from __future__ import annotations
|
||
import os
|
||
from datetime import datetime, timedelta, timezone
|
||
from enum import Enum
|
||
from typing import Any, Callable, Optional
|
||
|
||
import jwt
|
||
from fastapi import Depends, HTTPException, status
|
||
from fastapi.security import OAuth2PasswordBearer
|
||
from pydantic import BaseModel
|
||
|
||
JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
|
||
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
||
ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15"))
|
||
REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200"))
|
||
|
||
class TokenType(str, Enum):
|
||
access = "access"
|
||
refresh = "refresh"
|
||
|
||
class UserClaims(BaseModel):
|
||
sub: str
|
||
email: str
|
||
role: str
|
||
type: str
|
||
exp: int
|
||
|
||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
|
||
|
||
def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str:
|
||
now = datetime.now(timezone.utc)
|
||
exp = now + timedelta(minutes=expires_minutes)
|
||
payload: dict[str, Any] = {
|
||
"sub": sub,
|
||
"email": email,
|
||
"role": role,
|
||
"type": token_type.value,
|
||
"exp": int(exp.timestamp()),
|
||
}
|
||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||
|
||
def create_access_token(*, sub: str, email: str, role: str) -> str:
|
||
return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN)
|
||
|
||
def create_refresh_token(*, sub: str, email: str, role: str) -> str:
|
||
return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN)
|
||
|
||
def decode_token(token: str) -> UserClaims:
|
||
try:
|
||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||
return UserClaims(**payload)
|
||
except jwt.ExpiredSignatureError:
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||
except jwt.PyJWTError:
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||
|
||
def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
|
||
return decode_token(token)
|
||
|
||
def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]:
|
||
def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
|
||
if roles and user.role not in roles:
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
|
||
return user
|
||
return dep
|
||
PY
|
||
else
|
||
write_file "$SEC" <<'PY'
|
||
from __future__ import annotations
|
||
import os
|
||
from enum import Enum
|
||
from typing import Any, Callable
|
||
|
||
import jwt
|
||
from fastapi import Depends, HTTPException, status
|
||
from fastapi.security import OAuth2PasswordBearer
|
||
from pydantic import BaseModel
|
||
|
||
JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
|
||
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
||
|
||
class UserClaims(BaseModel):
|
||
sub: str
|
||
email: str
|
||
role: str
|
||
type: str
|
||
exp: int
|
||
|
||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
|
||
|
||
def decode_token(token: str) -> UserClaims:
|
||
try:
|
||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||
return UserClaims(**payload)
|
||
except jwt.ExpiredSignatureError:
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||
except jwt.PyJWTError:
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||
|
||
def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
|
||
return decode_token(token)
|
||
|
||
def require_roles(*roles: str):
|
||
def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
|
||
if roles and user.role not in roles:
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
|
||
return user
|
||
return dep
|
||
PY
|
||
fi
|
||
done
|
||
|
||
# -------------------------------------------------------------------
|
||
# 4) AUTH service — модели, CRUD, токены, эндпоинты
|
||
# -------------------------------------------------------------------
|
||
# models
|
||
write_file services/auth/src/app/models/user.py <<'PY'
|
||
from __future__ import annotations
|
||
import uuid
|
||
from datetime import datetime
|
||
from enum import Enum
|
||
|
||
from sqlalchemy import String, Boolean, DateTime
|
||
from sqlalchemy.dialects.postgresql import UUID
|
||
from sqlalchemy.orm import Mapped, mapped_column
|
||
from sqlalchemy.sql import func
|
||
|
||
from app.db.session import Base
|
||
|
||
class Role(str, Enum):
|
||
ADMIN = "ADMIN"
|
||
MATCHMAKER = "MATCHMAKER"
|
||
CLIENT = "CLIENT"
|
||
|
||
class User(Base):
|
||
__tablename__ = "users"
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||
full_name: Mapped[str | None] = mapped_column(String(255), default=None)
|
||
role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False)
|
||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||
PY
|
||
|
||
write_file services/auth/src/app/models/__init__.py <<'PY'
|
||
from .user import User, Role # noqa: F401
|
||
PY
|
||
|
||
# schemas
|
||
write_file services/auth/src/app/schemas/user.py <<'PY'
|
||
from __future__ import annotations
|
||
from typing import Optional
|
||
from pydantic import BaseModel, EmailStr, ConfigDict
|
||
|
||
class UserBase(BaseModel):
|
||
email: EmailStr
|
||
full_name: Optional[str] = None
|
||
role: str = "CLIENT"
|
||
is_active: bool = True
|
||
|
||
class UserCreate(BaseModel):
|
||
email: EmailStr
|
||
password: str
|
||
full_name: Optional[str] = None
|
||
role: str = "CLIENT"
|
||
|
||
class UserUpdate(BaseModel):
|
||
full_name: Optional[str] = None
|
||
role: Optional[str] = None
|
||
is_active: Optional[bool] = None
|
||
password: Optional[str] = None
|
||
|
||
class UserRead(BaseModel):
|
||
id: str
|
||
email: EmailStr
|
||
full_name: Optional[str] = None
|
||
role: str
|
||
is_active: bool
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
class LoginRequest(BaseModel):
|
||
email: EmailStr
|
||
password: str
|
||
|
||
class TokenPair(BaseModel):
|
||
access_token: str
|
||
refresh_token: str
|
||
token_type: str = "bearer"
|
||
PY
|
||
|
||
# passwords
|
||
write_file services/auth/src/app/core/passwords.py <<'PY'
|
||
from passlib.context import CryptContext
|
||
|
||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||
|
||
def hash_password(p: str) -> str:
|
||
return _pwd.hash(p)
|
||
|
||
def verify_password(p: str, hashed: str) -> bool:
|
||
return _pwd.verify(p, hashed)
|
||
PY
|
||
|
||
# repositories
|
||
write_file services/auth/src/app/repositories/user_repository.py <<'PY'
|
||
from __future__ import annotations
|
||
from typing import Optional, Sequence
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import select, update, delete
|
||
|
||
from app.models.user import User
|
||
|
||
class UserRepository:
|
||
def __init__(self, db: Session):
|
||
self.db = db
|
||
|
||
def get(self, user_id) -> Optional[User]:
|
||
return self.db.get(User, user_id)
|
||
|
||
def get_by_email(self, email: str) -> Optional[User]:
|
||
stmt = select(User).where(User.email == email)
|
||
return self.db.execute(stmt).scalar_one_or_none()
|
||
|
||
def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]:
|
||
stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc())
|
||
return self.db.execute(stmt).scalars().all()
|
||
|
||
def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User:
|
||
user = User(email=email, password_hash=password_hash, full_name=full_name, role=role)
|
||
self.db.add(user)
|
||
self.db.commit()
|
||
self.db.refresh(user)
|
||
return user
|
||
|
||
def update(self, user: User, **fields) -> User:
|
||
for k, v in fields.items():
|
||
if v is not None:
|
||
setattr(user, k, v)
|
||
self.db.add(user)
|
||
self.db.commit()
|
||
self.db.refresh(user)
|
||
return user
|
||
|
||
def delete(self, user: User) -> None:
|
||
self.db.delete(user)
|
||
self.db.commit()
|
||
PY
|
||
|
||
# services
|
||
write_file services/auth/src/app/services/user_service.py <<'PY'
|
||
from __future__ import annotations
|
||
from typing import Optional
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.repositories.user_repository import UserRepository
|
||
from app.core.passwords import hash_password, verify_password
|
||
from app.models.user import User
|
||
|
||
class UserService:
|
||
def __init__(self, db: Session):
|
||
self.repo = UserRepository(db)
|
||
|
||
# CRUD
|
||
def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User:
|
||
if self.repo.get_by_email(email):
|
||
raise ValueError("Email already in use")
|
||
pwd_hash = hash_password(password)
|
||
return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role)
|
||
|
||
def get_user(self, user_id) -> Optional[User]:
|
||
return self.repo.get(user_id)
|
||
|
||
def get_by_email(self, email: str) -> Optional[User]:
|
||
return self.repo.get_by_email(email)
|
||
|
||
def list_users(self, *, offset: int = 0, limit: int = 50):
|
||
return self.repo.list(offset=offset, limit=limit)
|
||
|
||
def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None,
|
||
is_active: bool | None = None, password: str | None = None) -> User:
|
||
fields = {}
|
||
if full_name is not None: fields["full_name"] = full_name
|
||
if role is not None: fields["role"] = role
|
||
if is_active is not None: fields["is_active"] = is_active
|
||
if password: fields["password_hash"] = hash_password(password)
|
||
return self.repo.update(user, **fields)
|
||
|
||
def delete_user(self, user: User) -> None:
|
||
self.repo.delete(user)
|
||
|
||
# Auth
|
||
def authenticate(self, *, email: str, password: str) -> Optional[User]:
|
||
user = self.repo.get_by_email(email)
|
||
if not user or not user.is_active:
|
||
return None
|
||
if not verify_password(password, user.password_hash):
|
||
return None
|
||
return user
|
||
PY
|
||
|
||
# api routes
|
||
write_file services/auth/src/app/api/routes/auth.py <<'PY'
|
||
from __future__ import annotations
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.db.session import get_db
|
||
from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead
|
||
from app.services.user_service import UserService
|
||
from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims
|
||
|
||
router = APIRouter(prefix="/v1", tags=["auth"])
|
||
|
||
@router.post("/register", response_model=UserRead, status_code=201)
|
||
def register(payload: UserCreate, db: Session = Depends(get_db)):
|
||
svc = UserService(db)
|
||
try:
|
||
user = svc.create_user(email=payload.email, password=payload.password,
|
||
full_name=payload.full_name, role=payload.role)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
return user
|
||
|
||
@router.post("/token", response_model=TokenPair)
|
||
def token(payload: LoginRequest, db: Session = Depends(get_db)):
|
||
svc = UserService(db)
|
||
user = svc.authenticate(email=payload.email, password=payload.password)
|
||
if not user:
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||
access = create_access_token(sub=str(user.id), email=user.email, role=user.role)
|
||
refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role)
|
||
return TokenPair(access_token=access, refresh_token=refresh)
|
||
|
||
class RefreshRequest(LoginRequest.__class__):
|
||
refresh_token: str # type: ignore
|
||
|
||
@router.post("/refresh", response_model=TokenPair)
|
||
def refresh_token(req: dict):
|
||
# expects: {"refresh_token": "<token>"}
|
||
from app.core.security import decode_token
|
||
token = req.get("refresh_token")
|
||
if not token:
|
||
raise HTTPException(status_code=400, detail="Missing refresh_token")
|
||
claims = decode_token(token)
|
||
if claims.type != "refresh":
|
||
raise HTTPException(status_code=400, detail="Not a refresh token")
|
||
access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role)
|
||
refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role)
|
||
return TokenPair(access_token=access, refresh_token=refresh)
|
||
|
||
@router.get("/me", response_model=UserRead)
|
||
def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)):
|
||
svc = UserService(db)
|
||
u = svc.get_user(claims.sub)
|
||
if not u:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
return u
|
||
PY
|
||
|
||
write_file services/auth/src/app/api/routes/users.py <<'PY'
|
||
from __future__ import annotations
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.db.session import get_db
|
||
from app.core.security import require_roles
|
||
from app.schemas.user import UserRead, UserUpdate, UserCreate
|
||
from app.services.user_service import UserService
|
||
|
||
router = APIRouter(prefix="/v1/users", tags=["users"])
|
||
|
||
@router.get("", response_model=list[UserRead])
|
||
def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db),
|
||
_: dict = Depends(require_roles("ADMIN"))):
|
||
return UserService(db).list_users(offset=offset, limit=limit)
|
||
|
||
@router.post("", response_model=UserRead, status_code=201)
|
||
def create_user(payload: UserCreate, db: Session = Depends(get_db),
|
||
_: dict = Depends(require_roles("ADMIN"))):
|
||
try:
|
||
return UserService(db).create_user(email=payload.email, password=payload.password,
|
||
full_name=payload.full_name, role=payload.role)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
|
||
@router.get("/{user_id}", response_model=UserRead)
|
||
def get_user(user_id: str, db: Session = Depends(get_db),
|
||
_: dict = Depends(require_roles("ADMIN"))):
|
||
u = UserService(db).get_user(user_id)
|
||
if not u:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
return u
|
||
|
||
@router.patch("/{user_id}", response_model=UserRead)
|
||
def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db),
|
||
_: dict = Depends(require_roles("ADMIN"))):
|
||
svc = UserService(db)
|
||
u = svc.get_user(user_id)
|
||
if not u:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
return svc.update_user(u, full_name=payload.full_name, role=payload.role,
|
||
is_active=payload.is_active, password=payload.password)
|
||
|
||
@router.delete("/{user_id}", status_code=204)
|
||
def delete_user(user_id: str, db: Session = Depends(get_db),
|
||
_: dict = Depends(require_roles("ADMIN"))):
|
||
svc = UserService(db)
|
||
u = svc.get_user(user_id)
|
||
if not u:
|
||
return
|
||
svc.delete_user(u)
|
||
PY
|
||
|
||
# main.py update for auth
|
||
write_file services/auth/src/app/main.py <<'PY'
|
||
from fastapi import FastAPI
|
||
from .api.routes.ping import router as ping_router
|
||
from .api.routes.auth import router as auth_router
|
||
from .api.routes.users import router as users_router
|
||
|
||
app = FastAPI(title="AUTH Service")
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok", "service": "auth"}
|
||
|
||
app.include_router(ping_router, prefix="/v1")
|
||
app.include_router(auth_router)
|
||
app.include_router(users_router)
|
||
PY
|
||
|
||
# -------------------------------------------------------------------
|
||
# 5) PROFILES service — Profile + Photo CRUD + поиск
|
||
# -------------------------------------------------------------------
|
||
write_file services/profiles/src/app/models/profile.py <<'PY'
|
||
from __future__ import annotations
|
||
import uuid
|
||
from datetime import date, datetime
|
||
|
||
from sqlalchemy import String, Date, DateTime, Text
|
||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||
from sqlalchemy.sql import func
|
||
|
||
from app.db.session import Base
|
||
|
||
class Profile(Base):
|
||
__tablename__ = "profiles"
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||
gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
|
||
birthdate: Mapped[date | None] = mapped_column(Date, default=None)
|
||
city: Mapped[str | None] = mapped_column(String(120), default=None)
|
||
bio: Mapped[str | None] = mapped_column(Text, default=None)
|
||
languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"]
|
||
interests: Mapped[dict | None] = mapped_column(JSONB, default=list)
|
||
preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict)
|
||
verification_status: Mapped[str] = mapped_column(String(16), default="unverified")
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||
|
||
photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan")
|
||
PY
|
||
|
||
write_file services/profiles/src/app/models/photo.py <<'PY'
|
||
from __future__ import annotations
|
||
import uuid
|
||
from datetime import datetime
|
||
|
||
from sqlalchemy import String, Boolean, DateTime, ForeignKey
|
||
from sqlalchemy.dialects.postgresql import UUID
|
||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||
from sqlalchemy.sql import func
|
||
|
||
from app.db.session import Base
|
||
|
||
class Photo(Base):
|
||
__tablename__ = "photos"
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||
url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
|
||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||
|
||
profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True)
|
||
PY
|
||
|
||
write_file services/profiles/src/app/models/__init__.py <<'PY'
|
||
from .profile import Profile # noqa
|
||
from .photo import Photo # noqa
|
||
PY
|
||
|
||
write_file services/profiles/src/app/schemas/profile.py <<'PY'
|
||
from __future__ import annotations
|
||
from datetime import date
|
||
from typing import Optional, Any
|
||
from pydantic import BaseModel, ConfigDict
|
||
|
||
class PhotoCreate(BaseModel):
|
||
url: str
|
||
is_main: bool = False
|
||
|
||
class PhotoRead(BaseModel):
|
||
id: str
|
||
url: str
|
||
is_main: bool
|
||
status: str
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
class ProfileCreate(BaseModel):
|
||
gender: str
|
||
birthdate: Optional[date] = None
|
||
city: Optional[str] = None
|
||
bio: Optional[str] = None
|
||
languages: Optional[list[str]] = None
|
||
interests: Optional[list[str]] = None
|
||
preferences: Optional[dict[str, Any]] = None
|
||
|
||
class ProfileUpdate(BaseModel):
|
||
gender: Optional[str] = None
|
||
birthdate: Optional[date] = None
|
||
city: Optional[str] = None
|
||
bio: Optional[str] = None
|
||
languages: Optional[list[str]] = None
|
||
interests: Optional[list[str]] = None
|
||
preferences: Optional[dict[str, Any]] = None
|
||
verification_status: Optional[str] = None
|
||
|
||
class ProfileRead(BaseModel):
|
||
id: str
|
||
user_id: str
|
||
gender: str
|
||
birthdate: Optional[date] = None
|
||
city: Optional[str] = None
|
||
bio: Optional[str] = None
|
||
languages: Optional[list[str]] = None
|
||
interests: Optional[list[str]] = None
|
||
preferences: Optional[dict] = None
|
||
verification_status: str
|
||
model_config = ConfigDict(from_attributes=True)
|
||
PY
|
||
|
||
write_file services/profiles/src/app/repositories/profile_repository.py <<'PY'
|
||
from __future__ import annotations
|
||
from typing import Optional, Sequence
|
||
from datetime import date, timedelta
|
||
|
||
from sqlalchemy import select, and_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models.profile import Profile
|
||
from app.models.photo import Photo
|
||
|
||
class ProfileRepository:
|
||
def __init__(self, db: Session):
|
||
self.db = db
|
||
|
||
# Profile CRUD
|
||
def create_profile(self, *, user_id, **fields) -> Profile:
|
||
p = Profile(user_id=user_id, **fields)
|
||
self.db.add(p)
|
||
self.db.commit()
|
||
self.db.refresh(p)
|
||
return p
|
||
|
||
def get_profile(self, profile_id) -> Optional[Profile]:
|
||
return self.db.get(Profile, profile_id)
|
||
|
||
def get_by_user(self, user_id) -> Optional[Profile]:
|
||
stmt = select(Profile).where(Profile.user_id == user_id)
|
||
return self.db.execute(stmt).scalar_one_or_none()
|
||
|
||
def update_profile(self, profile: Profile, **fields) -> Profile:
|
||
for k, v in fields.items():
|
||
if v is not None:
|
||
setattr(profile, k, v)
|
||
self.db.add(profile)
|
||
self.db.commit()
|
||
self.db.refresh(profile)
|
||
return profile
|
||
|
||
def delete_profile(self, profile: Profile) -> None:
|
||
self.db.delete(profile)
|
||
self.db.commit()
|
||
|
||
def list_profiles(self, *, gender: str | None = None, city: str | None = None,
|
||
age_min: int | None = None, age_max: int | None = None,
|
||
offset: int = 0, limit: int = 50) -> Sequence[Profile]:
|
||
stmt = select(Profile)
|
||
conds = []
|
||
if gender:
|
||
conds.append(Profile.gender == gender)
|
||
if city:
|
||
conds.append(Profile.city == city)
|
||
# Age filter -> birthdate between (today - age_max) and (today - age_min)
|
||
if age_min is not None or age_max is not None:
|
||
today = date.today()
|
||
if age_min is not None:
|
||
max_birthdate = date(today.year - age_min, today.month, today.day)
|
||
conds.append(Profile.birthdate <= max_birthdate)
|
||
if age_max is not None:
|
||
min_birthdate = date(today.year - age_max - 1, today.month, today.day) + timedelta(days=1)
|
||
conds.append(Profile.birthdate >= min_birthdate)
|
||
if conds:
|
||
stmt = stmt.where(and_(*conds))
|
||
stmt = stmt.offset(offset).limit(limit).order_by(Profile.created_at.desc())
|
||
return self.db.execute(stmt).scalars().all()
|
||
|
||
# Photos
|
||
def add_photo(self, *, profile_id, url: str, is_main: bool) -> Photo:
|
||
photo = Photo(profile_id=profile_id, url=url, is_main=is_main)
|
||
self.db.add(photo)
|
||
if is_main:
|
||
# unset other main photos
|
||
self.db.execute(select(Photo).where(Photo.profile_id == profile_id))
|
||
self.db.commit()
|
||
self.db.refresh(photo)
|
||
return photo
|
||
|
||
def list_photos(self, *, profile_id) -> Sequence[Photo]:
|
||
stmt = select(Photo).where(Photo.profile_id == profile_id)
|
||
return self.db.execute(stmt).scalars().all()
|
||
|
||
def get_photo(self, photo_id) -> Optional[Photo]:
|
||
return self.db.get(Photo, photo_id)
|
||
|
||
def delete_photo(self, photo: Photo) -> None:
|
||
self.db.delete(photo)
|
||
self.db.commit()
|
||
PY
|
||
|
||
write_file services/profiles/src/app/services/profile_service.py <<'PY'
|
||
from __future__ import annotations
|
||
from sqlalchemy.orm import Session
|
||
from typing import Optional
|
||
|
||
from app.repositories.profile_repository import ProfileRepository
|
||
from app.models.profile import Profile
|
||
from app.models.photo import Photo
|
||
|
||
class ProfileService:
|
||
def __init__(self, db: Session):
|
||
self.repo = ProfileRepository(db)
|
||
|
||
def create_profile(self, *, user_id, **fields) -> Profile:
|
||
return self.repo.create_profile(user_id=user_id, **fields)
|
||
|
||
def get_profile(self, profile_id) -> Optional[Profile]:
|
||
return self.repo.get_profile(profile_id)
|
||
|
||
def get_by_user(self, user_id) -> Optional[Profile]:
|
||
return self.repo.get_by_user(user_id)
|
||
|
||
def update_profile(self, profile: Profile, **fields) -> Profile:
|
||
return self.repo.update_profile(profile, **fields)
|
||
|
||
def delete_profile(self, profile: Profile) -> None:
|
||
return self.repo.delete_profile(profile)
|
||
|
||
def list_profiles(self, **filters):
|
||
return self.repo.list_profiles(**filters)
|
||
|
||
# photos
|
||
def add_photo(self, profile_id, *, url: str, is_main: bool) -> Photo:
|
||
return self.repo.add_photo(profile_id=profile_id, url=url, is_main=is_main)
|
||
|
||
def list_photos(self, profile_id):
|
||
return self.repo.list_photos(profile_id=profile_id)
|
||
|
||
def get_photo(self, photo_id) -> Photo | None:
|
||
return self.repo.get_photo(photo_id)
|
||
|
||
def delete_photo(self, photo: Photo) -> None:
|
||
self.repo.delete_photo(photo)
|
||
PY
|
||
|
||
write_file services/profiles/src/app/api/routes/profiles.py <<'PY'
|
||
from __future__ import annotations
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.db.session import get_db
|
||
from app.core.security import get_current_user, require_roles, UserClaims
|
||
from app.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate, PhotoCreate, PhotoRead
|
||
from app.services.profile_service import ProfileService
|
||
|
||
router = APIRouter(prefix="/v1", tags=["profiles"])
|
||
|
||
@router.post("/profiles", response_model=ProfileRead, status_code=201)
|
||
def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
svc = ProfileService(db)
|
||
if svc.get_by_user(user.sub):
|
||
raise HTTPException(status_code=400, detail="Profile already exists")
|
||
p = svc.create_profile(user_id=user.sub, **payload.model_dump(exclude_none=True))
|
||
return p
|
||
|
||
@router.get("/profiles/me", response_model=ProfileRead)
|
||
def get_my_profile(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
svc = ProfileService(db)
|
||
p = svc.get_by_user(user.sub)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="Profile not found")
|
||
return p
|
||
|
||
@router.get("/profiles", response_model=list[ProfileRead])
|
||
def list_profiles(gender: str | None = None, city: str | None = None,
|
||
age_min: int | None = Query(None, ge=18, le=120),
|
||
age_max: int | None = Query(None, ge=18, le=120),
|
||
offset: int = 0, limit: int = Query(50, le=200),
|
||
db: Session = Depends(get_db),
|
||
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
|
||
return ProfileService(db).list_profiles(gender=gender, city=city, age_min=age_min, age_max=age_max, offset=offset, limit=limit)
|
||
|
||
@router.get("/profiles/{profile_id}", response_model=ProfileRead)
|
||
def get_profile(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
|
||
p = ProfileService(db).get_profile(profile_id)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="Profile not found")
|
||
return p
|
||
|
||
@router.patch("/profiles/{profile_id}", response_model=ProfileRead)
|
||
def update_profile(profile_id: str, payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
svc = ProfileService(db)
|
||
p = svc.get_profile(profile_id)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="Profile not found")
|
||
if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
|
||
raise HTTPException(status_code=403, detail="Not allowed")
|
||
return svc.update_profile(p, **payload.model_dump(exclude_none=True))
|
||
|
||
@router.delete("/profiles/{profile_id}", status_code=204)
|
||
def delete_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
svc = ProfileService(db)
|
||
p = svc.get_profile(profile_id)
|
||
if not p:
|
||
return
|
||
if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
|
||
raise HTTPException(status_code=403, detail="Not allowed")
|
||
svc.delete_profile(p)
|
||
|
||
# Photos
|
||
@router.post("/profiles/{profile_id}/photos", response_model=PhotoRead, status_code=201)
|
||
def add_photo(profile_id: str, payload: PhotoCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
svc = ProfileService(db)
|
||
p = svc.get_profile(profile_id)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="Profile not found")
|
||
if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
|
||
raise HTTPException(status_code=403, detail="Not allowed")
|
||
photo = svc.add_photo(profile_id, url=payload.url, is_main=payload.is_main)
|
||
return photo
|
||
|
||
@router.get("/profiles/{profile_id}/photos", response_model=list[PhotoRead])
|
||
def list_photos(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
|
||
svc = ProfileService(db)
|
||
return svc.list_photos(profile_id)
|
||
|
||
@router.delete("/photos/{photo_id}", status_code=204)
|
||
def delete_photo(photo_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
svc = ProfileService(db)
|
||
photo = svc.get_photo(photo_id)
|
||
if not photo:
|
||
return
|
||
# Lookup profile to check ownership
|
||
p = svc.get_profile(photo.profile_id)
|
||
if not p or (str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER")):
|
||
raise HTTPException(status_code=403, detail="Not allowed")
|
||
svc.delete_photo(photo)
|
||
PY
|
||
|
||
# main.py for profiles
|
||
write_file services/profiles/src/app/main.py <<'PY'
|
||
from fastapi import FastAPI
|
||
from .api.routes.ping import router as ping_router
|
||
from .api.routes.profiles import router as profiles_router
|
||
|
||
app = FastAPI(title="PROFILES Service")
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok", "service": "profiles"}
|
||
|
||
app.include_router(ping_router, prefix="/v1")
|
||
app.include_router(profiles_router)
|
||
PY
|
||
|
||
# -------------------------------------------------------------------
|
||
# 6) MATCH service — пары и статусы (proposed/accepted/rejected/blocked)
|
||
# -------------------------------------------------------------------
|
||
write_file services/match/src/app/models/pair.py <<'PY'
|
||
from __future__ import annotations
|
||
import uuid
|
||
from datetime import datetime
|
||
from sqlalchemy import String, Float, DateTime
|
||
from sqlalchemy.dialects.postgresql import UUID
|
||
from sqlalchemy.orm import Mapped, mapped_column
|
||
from sqlalchemy.sql import func
|
||
|
||
from app.db.session import Base
|
||
|
||
class MatchPair(Base):
|
||
__tablename__ = "match_pairs"
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
# User IDs to validate permissions; profile IDs можно добавить позже
|
||
user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||
user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||
status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked
|
||
score: Mapped[float | None] = mapped_column(Float, default=None)
|
||
notes: Mapped[str | None] = mapped_column(String(1000), default=None)
|
||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||
PY
|
||
|
||
write_file services/match/src/app/models/__init__.py <<'PY'
|
||
from .pair import MatchPair # noqa
|
||
PY
|
||
|
||
write_file services/match/src/app/schemas/pair.py <<'PY'
|
||
from __future__ import annotations
|
||
from typing import Optional
|
||
from pydantic import BaseModel, ConfigDict
|
||
|
||
class PairCreate(BaseModel):
|
||
user_id_a: str
|
||
user_id_b: str
|
||
score: Optional[float] = None
|
||
notes: Optional[str] = None
|
||
|
||
class PairUpdate(BaseModel):
|
||
score: Optional[float] = None
|
||
notes: Optional[str] = None
|
||
|
||
class PairRead(BaseModel):
|
||
id: str
|
||
user_id_a: str
|
||
user_id_b: str
|
||
status: str
|
||
score: Optional[float] = None
|
||
notes: Optional[str] = None
|
||
model_config = ConfigDict(from_attributes=True)
|
||
PY
|
||
|
||
write_file services/match/src/app/repositories/pair_repository.py <<'PY'
|
||
from __future__ import annotations
|
||
from typing import Optional, Sequence
|
||
from sqlalchemy import select, or_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models.pair import MatchPair
|
||
|
||
class PairRepository:
|
||
def __init__(self, db: Session):
|
||
self.db = db
|
||
|
||
def create(self, **fields) -> MatchPair:
|
||
obj = MatchPair(**fields)
|
||
self.db.add(obj)
|
||
self.db.commit()
|
||
self.db.refresh(obj)
|
||
return obj
|
||
|
||
def get(self, pair_id) -> Optional[MatchPair]:
|
||
return self.db.get(MatchPair, pair_id)
|
||
|
||
def list(self, *, for_user_id: str | None = None, status: str | None = None,
|
||
offset: int = 0, limit: int = 50) -> Sequence[MatchPair]:
|
||
stmt = select(MatchPair)
|
||
if for_user_id:
|
||
stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id))
|
||
if status:
|
||
stmt = stmt.where(MatchPair.status == status)
|
||
stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc())
|
||
return self.db.execute(stmt).scalars().all()
|
||
|
||
def update(self, obj: MatchPair, **fields) -> MatchPair:
|
||
for k, v in fields.items():
|
||
if v is not None:
|
||
setattr(obj, k, v)
|
||
self.db.add(obj)
|
||
self.db.commit()
|
||
self.db.refresh(obj)
|
||
return obj
|
||
|
||
def delete(self, obj: MatchPair) -> None:
|
||
self.db.delete(obj)
|
||
self.db.commit()
|
||
PY
|
||
|
||
write_file services/match/src/app/services/pair_service.py <<'PY'
|
||
from __future__ import annotations
|
||
from sqlalchemy.orm import Session
|
||
from typing import Optional
|
||
from app.repositories.pair_repository import PairRepository
|
||
from app.models.pair import MatchPair
|
||
|
||
class PairService:
|
||
def __init__(self, db: Session):
|
||
self.repo = PairRepository(db)
|
||
|
||
def create(self, **fields) -> MatchPair:
|
||
return self.repo.create(**fields)
|
||
|
||
def get(self, pair_id) -> Optional[MatchPair]:
|
||
return self.repo.get(pair_id)
|
||
|
||
def list(self, **filters):
|
||
return self.repo.list(**filters)
|
||
|
||
def update(self, obj: MatchPair, **fields) -> MatchPair:
|
||
return self.repo.update(obj, **fields)
|
||
|
||
def delete(self, obj: MatchPair) -> None:
|
||
return self.repo.delete(obj)
|
||
|
||
def set_status(self, obj: MatchPair, status: str) -> MatchPair:
|
||
return self.repo.update(obj, status=status)
|
||
PY
|
||
|
||
write_file services/match/src/app/api/routes/pairs.py <<'PY'
|
||
from __future__ import annotations
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.db.session import get_db
|
||
from app.core.security import get_current_user, require_roles, UserClaims
|
||
from app.schemas.pair import PairCreate, PairUpdate, PairRead
|
||
from app.services.pair_service import PairService
|
||
|
||
router = APIRouter(prefix="/v1/pairs", tags=["pairs"])
|
||
|
||
@router.post("", response_model=PairRead, status_code=201)
|
||
def create_pair(payload: PairCreate, db: Session = Depends(get_db),
|
||
user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
|
||
svc = PairService(db)
|
||
return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b,
|
||
score=payload.score, notes=payload.notes, created_by=user.sub)
|
||
|
||
@router.get("", response_model=list[PairRead])
|
||
def list_pairs(for_user_id: str | None = None, status: str | None = None,
|
||
offset: int = 0, limit: int = Query(50, le=200),
|
||
db: Session = Depends(get_db),
|
||
_: UserClaims = Depends(get_current_user)):
|
||
return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit)
|
||
|
||
@router.get("/{pair_id}", response_model=PairRead)
|
||
def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
|
||
obj = PairService(db).get(pair_id)
|
||
if not obj:
|
||
raise HTTPException(status_code=404, detail="Not found")
|
||
return obj
|
||
|
||
@router.patch("/{pair_id}", response_model=PairRead)
|
||
def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db),
|
||
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
|
||
svc = PairService(db)
|
||
obj = svc.get(pair_id)
|
||
if not obj:
|
||
raise HTTPException(status_code=404, detail="Not found")
|
||
return svc.update(obj, **payload.model_dump(exclude_none=True))
|
||
|
||
@router.post("/{pair_id}/accept", response_model=PairRead)
|
||
def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
svc = PairService(db)
|
||
obj = svc.get(pair_id)
|
||
if not obj:
|
||
raise HTTPException(status_code=404, detail="Not found")
|
||
# Validate that current user participates
|
||
if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
|
||
raise HTTPException(status_code=403, detail="Not allowed")
|
||
return svc.set_status(obj, "accepted")
|
||
|
||
@router.post("/{pair_id}/reject", response_model=PairRead)
|
||
def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
svc = PairService(db)
|
||
obj = svc.get(pair_id)
|
||
if not obj:
|
||
raise HTTPException(status_code=404, detail="Not found")
|
||
if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
|
||
raise HTTPException(status_code=403, detail="Not allowed")
|
||
return svc.set_status(obj, "rejected")
|
||
|
||
@router.delete("/{pair_id}", status_code=204)
|
||
def delete_pair(pair_id: str, db: Session = Depends(get_db),
|
||
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
|
||
svc = PairService(db)
|
||
obj = svc.get(pair_id)
|
||
if not obj:
|
||
return
|
||
svc.delete(obj)
|
||
PY
|
||
|
||
write_file services/match/src/app/main.py <<'PY'
|
||
from fastapi import FastAPI
|
||
from .api.routes.ping import router as ping_router
|
||
from .api.routes.pairs import router as pairs_router
|
||
|
||
app = FastAPI(title="MATCH Service")
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok", "service": "match"}
|
||
|
||
app.include_router(ping_router, prefix="/v1")
|
||
app.include_router(pairs_router)
|
||
PY
|
||
|
||
# -------------------------------------------------------------------
|
||
# 7) CHAT service — комнаты и сообщения (REST, без WS)
|
||
# -------------------------------------------------------------------
|
||
write_file services/chat/src/app/models/chat.py <<'PY'
|
||
from __future__ import annotations
|
||
import uuid
|
||
from datetime import datetime
|
||
from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean
|
||
from sqlalchemy.dialects.postgresql import UUID
|
||
from sqlalchemy.orm import Mapped, mapped_column
|
||
from sqlalchemy.sql import func
|
||
|
||
from app.db.session import Base
|
||
|
||
class ChatRoom(Base):
|
||
__tablename__ = "chat_rooms"
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
title: Mapped[str | None] = mapped_column(String(255), default=None)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||
|
||
class ChatParticipant(Base):
|
||
__tablename__ = "chat_participants"
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||
|
||
class Message(Base):
|
||
__tablename__ = "chat_messages"
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||
sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||
PY
|
||
|
||
write_file services/chat/src/app/models/__init__.py <<'PY'
|
||
from .chat import ChatRoom, ChatParticipant, Message # noqa
|
||
PY
|
||
|
||
write_file services/chat/src/app/schemas/chat.py <<'PY'
|
||
from __future__ import annotations
|
||
from pydantic import BaseModel, ConfigDict
|
||
from typing import Optional
|
||
|
||
class RoomCreate(BaseModel):
|
||
title: Optional[str] = None
|
||
participants: list[str] # user IDs
|
||
|
||
class RoomRead(BaseModel):
|
||
id: str
|
||
title: Optional[str] = None
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
class MessageCreate(BaseModel):
|
||
content: str
|
||
|
||
class MessageRead(BaseModel):
|
||
id: str
|
||
room_id: str
|
||
sender_id: str
|
||
content: str
|
||
model_config = ConfigDict(from_attributes=True)
|
||
PY
|
||
|
||
write_file services/chat/src/app/repositories/chat_repository.py <<'PY'
|
||
from __future__ import annotations
|
||
from typing import Sequence, Optional
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import select, or_
|
||
|
||
from app.models.chat import ChatRoom, ChatParticipant, Message
|
||
|
||
class ChatRepository:
|
||
def __init__(self, db: Session):
|
||
self.db = db
|
||
|
||
# Rooms
|
||
def create_room(self, title: str | None) -> ChatRoom:
|
||
r = ChatRoom(title=title)
|
||
self.db.add(r)
|
||
self.db.commit()
|
||
self.db.refresh(r)
|
||
return r
|
||
|
||
def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant:
|
||
p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin)
|
||
self.db.add(p)
|
||
self.db.commit()
|
||
self.db.refresh(p)
|
||
return p
|
||
|
||
def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]:
|
||
stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\
|
||
.where(ChatParticipant.user_id == user_id)
|
||
return self.db.execute(stmt).scalars().all()
|
||
|
||
def get_room(self, room_id) -> Optional[ChatRoom]:
|
||
return self.db.get(ChatRoom, room_id)
|
||
|
||
# Messages
|
||
def create_message(self, room_id, sender_id, content: str) -> Message:
|
||
m = Message(room_id=room_id, sender_id=sender_id, content=content)
|
||
self.db.add(m)
|
||
self.db.commit()
|
||
self.db.refresh(m)
|
||
return m
|
||
|
||
def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]:
|
||
stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc())
|
||
return self.db.execute(stmt).scalars().all()
|
||
PY
|
||
|
||
write_file services/chat/src/app/services/chat_service.py <<'PY'
|
||
from __future__ import annotations
|
||
from sqlalchemy.orm import Session
|
||
from typing import Optional, Sequence
|
||
|
||
from app.repositories.chat_repository import ChatRepository
|
||
from app.models.chat import ChatRoom, ChatParticipant, Message
|
||
|
||
class ChatService:
|
||
def __init__(self, db: Session):
|
||
self.repo = ChatRepository(db)
|
||
|
||
def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom:
|
||
room = self.repo.create_room(title)
|
||
# creator -> admin
|
||
self.repo.add_participant(room.id, creator_id, is_admin=True)
|
||
for uid in participant_ids:
|
||
if uid != creator_id:
|
||
self.repo.add_participant(room.id, uid, is_admin=False)
|
||
return room
|
||
|
||
def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]:
|
||
return self.repo.list_rooms_for_user(user_id)
|
||
|
||
def get_room(self, room_id: str) -> ChatRoom | None:
|
||
return self.repo.get_room(room_id)
|
||
|
||
def create_message(self, room_id: str, sender_id: str, content: str) -> Message:
|
||
return self.repo.create_message(room_id, sender_id, content)
|
||
|
||
def list_messages(self, room_id: str, offset: int = 0, limit: int = 100):
|
||
return self.repo.list_messages(room_id, offset=offset, limit=limit)
|
||
PY
|
||
|
||
write_file services/chat/src/app/api/routes/chat.py <<'PY'
|
||
from __future__ import annotations
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.db.session import get_db
|
||
from app.core.security import get_current_user, UserClaims
|
||
from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead
|
||
from app.services.chat_service import ChatService
|
||
|
||
router = APIRouter(prefix="/v1", tags=["chat"])
|
||
|
||
@router.post("/rooms", response_model=RoomRead, status_code=201)
|
||
def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
svc = ChatService(db)
|
||
room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub)
|
||
return room
|
||
|
||
@router.get("/rooms", response_model=list[RoomRead])
|
||
def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
return ChatService(db).list_rooms_for_user(user.sub)
|
||
|
||
@router.get("/rooms/{room_id}", response_model=RoomRead)
|
||
def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
room = ChatService(db).get_room(room_id)
|
||
if not room:
|
||
raise HTTPException(status_code=404, detail="Not found")
|
||
# NOTE: для простоты опускаем проверку участия (добавьте в проде)
|
||
return room
|
||
|
||
@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201)
|
||
def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
svc = ChatService(db)
|
||
room = svc.get_room(room_id)
|
||
if not room:
|
||
raise HTTPException(status_code=404, detail="Room not found")
|
||
msg = svc.create_message(room_id, user.sub, payload.content)
|
||
return msg
|
||
|
||
@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead])
|
||
def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500),
|
||
db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
svc = ChatService(db)
|
||
room = svc.get_room(room_id)
|
||
if not room:
|
||
raise HTTPException(status_code=404, detail="Room not found")
|
||
return svc.list_messages(room_id, offset=offset, limit=limit)
|
||
PY
|
||
|
||
write_file services/chat/src/app/main.py <<'PY'
|
||
from fastapi import FastAPI
|
||
from .api.routes.ping import router as ping_router
|
||
from .api.routes.chat import router as chat_router
|
||
|
||
app = FastAPI(title="CHAT Service")
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok", "service": "chat"}
|
||
|
||
app.include_router(ping_router, prefix="/v1")
|
||
app.include_router(chat_router)
|
||
PY
|
||
|
||
# -------------------------------------------------------------------
|
||
# 8) PAYMENTS service — инвойсы (простая версия)
|
||
# -------------------------------------------------------------------
|
||
write_file services/payments/src/app/models/payment.py <<'PY'
|
||
from __future__ import annotations
|
||
import uuid
|
||
from datetime import datetime
|
||
from sqlalchemy import String, DateTime, Numeric
|
||
from sqlalchemy.dialects.postgresql import UUID
|
||
from sqlalchemy.orm import Mapped, mapped_column
|
||
from sqlalchemy.sql import func
|
||
|
||
from app.db.session import Base
|
||
|
||
class Invoice(Base):
|
||
__tablename__ = "invoices"
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||
amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False)
|
||
currency: Mapped[str] = mapped_column(String(3), default="USD")
|
||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled
|
||
description: Mapped[str | None] = mapped_column(String(500), default=None)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||
PY
|
||
|
||
write_file services/payments/src/app/models/__init__.py <<'PY'
|
||
from .payment import Invoice # noqa
|
||
PY
|
||
|
||
write_file services/payments/src/app/schemas/payment.py <<'PY'
|
||
from __future__ import annotations
|
||
from typing import Optional
|
||
from pydantic import BaseModel, ConfigDict
|
||
|
||
class InvoiceCreate(BaseModel):
|
||
client_id: str
|
||
amount: float
|
||
currency: str = "USD"
|
||
description: Optional[str] = None
|
||
|
||
class InvoiceUpdate(BaseModel):
|
||
amount: Optional[float] = None
|
||
currency: Optional[str] = None
|
||
description: Optional[str] = None
|
||
status: Optional[str] = None
|
||
|
||
class InvoiceRead(BaseModel):
|
||
id: str
|
||
client_id: str
|
||
amount: float
|
||
currency: str
|
||
status: str
|
||
description: Optional[str] = None
|
||
model_config = ConfigDict(from_attributes=True)
|
||
PY
|
||
|
||
write_file services/payments/src/app/repositories/payment_repository.py <<'PY'
|
||
from __future__ import annotations
|
||
from typing import Optional, Sequence
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import select
|
||
|
||
from app.models.payment import Invoice
|
||
|
||
class PaymentRepository:
|
||
def __init__(self, db: Session):
|
||
self.db = db
|
||
|
||
def create_invoice(self, **fields) -> Invoice:
|
||
obj = Invoice(**fields)
|
||
self.db.add(obj)
|
||
self.db.commit()
|
||
self.db.refresh(obj)
|
||
return obj
|
||
|
||
def get_invoice(self, inv_id) -> Optional[Invoice]:
|
||
return self.db.get(Invoice, inv_id)
|
||
|
||
def list_invoices(self, *, client_id: str | None = None, status: str | None = None,
|
||
offset: int = 0, limit: int = 50) -> Sequence[Invoice]:
|
||
stmt = select(Invoice)
|
||
if client_id:
|
||
stmt = stmt.where(Invoice.client_id == client_id)
|
||
if status:
|
||
stmt = stmt.where(Invoice.status == status)
|
||
stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc())
|
||
return self.db.execute(stmt).scalars().all()
|
||
|
||
def update_invoice(self, obj: Invoice, **fields) -> Invoice:
|
||
for k, v in fields.items():
|
||
if v is not None:
|
||
setattr(obj, k, v)
|
||
self.db.add(obj)
|
||
self.db.commit()
|
||
self.db.refresh(obj)
|
||
return obj
|
||
|
||
def delete_invoice(self, obj: Invoice) -> None:
|
||
self.db.delete(obj)
|
||
self.db.commit()
|
||
PY
|
||
|
||
write_file services/payments/src/app/services/payment_service.py <<'PY'
|
||
from __future__ import annotations
|
||
from sqlalchemy.orm import Session
|
||
from typing import Optional
|
||
from app.repositories.payment_repository import PaymentRepository
|
||
from app.models.payment import Invoice
|
||
|
||
class PaymentService:
|
||
def __init__(self, db: Session):
|
||
self.repo = PaymentRepository(db)
|
||
|
||
def create_invoice(self, **fields) -> Invoice:
|
||
return self.repo.create_invoice(**fields)
|
||
|
||
def get_invoice(self, inv_id) -> Invoice | None:
|
||
return self.repo.get_invoice(inv_id)
|
||
|
||
def list_invoices(self, **filters):
|
||
return self.repo.list_invoices(**filters)
|
||
|
||
def update_invoice(self, obj: Invoice, **fields) -> Invoice:
|
||
return self.repo.update_invoice(obj, **fields)
|
||
|
||
def delete_invoice(self, obj: Invoice) -> None:
|
||
return self.repo.delete_invoice(obj)
|
||
|
||
def mark_paid(self, obj: Invoice) -> Invoice:
|
||
return self.repo.update_invoice(obj, status="paid")
|
||
PY
|
||
|
||
write_file services/payments/src/app/api/routes/payments.py <<'PY'
|
||
from __future__ import annotations
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.db.session import get_db
|
||
from app.core.security import get_current_user, require_roles, UserClaims
|
||
from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead
|
||
from app.services.payment_service import PaymentService
|
||
|
||
router = APIRouter(prefix="/v1/invoices", tags=["payments"])
|
||
|
||
@router.post("", response_model=InvoiceRead, status_code=201)
|
||
def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db),
|
||
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
|
||
return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True))
|
||
|
||
@router.get("", response_model=list[InvoiceRead])
|
||
def list_invoices(client_id: str | None = None, status: str | None = None,
|
||
offset: int = 0, limit: int = Query(50, le=200),
|
||
db: Session = Depends(get_db),
|
||
user: UserClaims = Depends(get_current_user)):
|
||
# Клиент видит только свои инвойсы, админ/матчмейкер — любые
|
||
if user.role in ("ADMIN","MATCHMAKER"):
|
||
return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit)
|
||
else:
|
||
return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit)
|
||
|
||
@router.get("/{inv_id}", response_model=InvoiceRead)
|
||
def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
|
||
inv = PaymentService(db).get_invoice(inv_id)
|
||
if not inv:
|
||
raise HTTPException(status_code=404, detail="Not found")
|
||
if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub:
|
||
raise HTTPException(status_code=403, detail="Not allowed")
|
||
return inv
|
||
|
||
@router.patch("/{inv_id}", response_model=InvoiceRead)
|
||
def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db),
|
||
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
|
||
svc = PaymentService(db)
|
||
inv = svc.get_invoice(inv_id)
|
||
if not inv:
|
||
raise HTTPException(status_code=404, detail="Not found")
|
||
return svc.update_invoice(inv, **payload.model_dump(exclude_none=True))
|
||
|
||
@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead)
|
||
def mark_paid(inv_id: str, db: Session = Depends(get_db),
|
||
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
|
||
svc = PaymentService(db)
|
||
inv = svc.get_invoice(inv_id)
|
||
if not inv:
|
||
raise HTTPException(status_code=404, detail="Not found")
|
||
return svc.mark_paid(inv)
|
||
|
||
@router.delete("/{inv_id}", status_code=204)
|
||
def delete_invoice(inv_id: str, db: Session = Depends(get_db),
|
||
_: UserClaims = Depends(require_roles("ADMIN"))):
|
||
svc = PaymentService(db)
|
||
inv = svc.get_invoice(inv_id)
|
||
if not inv:
|
||
return
|
||
svc.delete_invoice(inv)
|
||
PY
|
||
|
||
write_file services/payments/src/app/main.py <<'PY'
|
||
from fastapi import FastAPI
|
||
from .api.routes.ping import router as ping_router
|
||
from .api.routes.payments import router as payments_router
|
||
|
||
app = FastAPI(title="PAYMENTS Service")
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok", "service": "payments"}
|
||
|
||
app.include_router(ping_router, prefix="/v1")
|
||
app.include_router(payments_router)
|
||
PY
|
||
|
||
# -------------------------------------------------------------------
|
||
# 9) Обновить __init__.py пакетов (если scaffold создал пустые)
|
||
# -------------------------------------------------------------------
|
||
for s in "${SERVICES[@]}"; do
|
||
touch "services/$s/src/app/__init__.py"
|
||
touch "services/$s/src/app/api/__init__.py"
|
||
touch "services/$s/src/app/api/routes/__init__.py"
|
||
touch "services/$s/src/app/core/__init__.py"
|
||
touch "services/$s/src/app/db/__init__.py"
|
||
touch "services/$s/src/app/repositories/__init__.py"
|
||
touch "services/$s/src/app/schemas/__init__.py"
|
||
touch "services/$s/src/app/services/__init__.py"
|
||
done
|
||
|
||
for s in auth profiles match chat payments; do
|
||
docker compose run --rm $s alembic revision --autogenerate -m "init"
|
||
done
|
||
|
||
echo "✅ Models + CRUD + API + Auth applied."
|
||
|
||
cat <<'NEXT'
|
||
Next steps:
|
||
|
||
1) Сгенерируйте первичные миграции по моделям:
|
||
for s in auth profiles match chat payments; do
|
||
docker compose run --rm $s alembic revision --autogenerate -m "init"
|
||
done
|
||
|
||
2) Поднимите окружение (alembic upgrade выполнится в entrypoint):
|
||
docker compose up --build
|
||
|
||
3) Получите токен:
|
||
POST http://localhost:8080/auth/v1/register
|
||
POST http://localhost:8080/auth/v1/token
|
||
-> Authorization: Bearer <access_token>
|
||
|
||
4) Проверьте CRUD:
|
||
- Profiles: GET http://localhost:8080/profiles/v1/profiles/me
|
||
- Match: POST http://localhost:8080/match/v1/pairs
|
||
- Chat: POST http://localhost:8080/chat/v1/rooms
|
||
- Payments: POST http://localhost:8080/payments/v1/invoices
|
||
|
||
Замечания по безопасности/продакшену:
|
||
- Секрет JWT смените в .env (JWT_SECRET), храните в секретах CI/CD.
|
||
- Сроки жизни токенов подберите под бизнес-политику.
|
||
- Для refresh-токенов сейчас статeless-вариант; при необходимости добавьте
|
||
хранилище jti/ревокацию.
|
||
- Для CHAT и MATCH в проде добавьте строгую проверку участия/прав.
|
||
- В PROFILES поля languages/interests/preferences — JSONB; при желании замените
|
||
на нормализованные таблицы или ARRAY.
|
||
NEXT
|