This commit is contained in:
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
|
||||
sys.path.append(SRC_DIR)
|
||||
|
||||
from app.db.session import Base # noqa
|
||||
from app import models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
22
services/auth/alembic/script.py.mako
Normal file
22
services/auth/alembic/script.py.mako
Normal file
@@ -0,0 +1,22 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
38
services/auth/alembic/versions/df0effc5d87a_init.py
Normal file
38
services/auth/alembic/versions/df0effc5d87a_init.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""init
|
||||
|
||||
Revision ID: df0effc5d87a
|
||||
Revises:
|
||||
Create Date: 2025-08-08 11:20:03.816755+00:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'df0effc5d87a'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('full_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('role', sa.String(length=32), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
||||
@@ -5,6 +5,9 @@ psycopg2-binary
|
||||
alembic
|
||||
pydantic>=2
|
||||
pydantic-settings
|
||||
pydantic[email]
|
||||
python-dotenv
|
||||
httpx>=0.27
|
||||
pytest
|
||||
PyJWT>=2.8
|
||||
passlib[bcrypt]>=1.7
|
||||
|
||||
55
services/auth/src/app/api/routes/auth.py
Normal file
55
services/auth/src/app/api/routes/auth.py
Normal file
@@ -0,0 +1,55 @@
|
||||
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
|
||||
51
services/auth/src/app/api/routes/users.py
Normal file
51
services/auth/src/app/api/routes/users.py
Normal file
@@ -0,0 +1,51 @@
|
||||
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)
|
||||
9
services/auth/src/app/core/passwords.py
Normal file
9
services/auth/src/app/core/passwords.py
Normal file
@@ -0,0 +1,9 @@
|
||||
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)
|
||||
65
services/auth/src/app/core/security.py
Normal file
65
services/auth/src/app/core/security.py
Normal file
@@ -0,0 +1,65 @@
|
||||
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
|
||||
@@ -1,5 +1,7 @@
|
||||
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")
|
||||
|
||||
@@ -7,5 +9,6 @@ app = FastAPI(title="AUTH Service")
|
||||
def health():
|
||||
return {"status": "ok", "service": "auth"}
|
||||
|
||||
# v1 API
|
||||
app.include_router(ping_router, prefix="/v1")
|
||||
app.include_router(auth_router)
|
||||
app.include_router(users_router)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .user import User, Role # noqa: F401
|
||||
|
||||
28
services/auth/src/app/models/user.py
Normal file
28
services/auth/src/app/models/user.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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)
|
||||
41
services/auth/src/app/repositories/user_repository.py
Normal file
41
services/auth/src/app/repositories/user_repository.py
Normal file
@@ -0,0 +1,41 @@
|
||||
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()
|
||||
38
services/auth/src/app/schemas/user.py
Normal file
38
services/auth/src/app/schemas/user.py
Normal file
@@ -0,0 +1,38 @@
|
||||
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"
|
||||
48
services/auth/src/app/services/user_service.py
Normal file
48
services/auth/src/app/services/user_service.py
Normal file
@@ -0,0 +1,48 @@
|
||||
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
|
||||
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
|
||||
sys.path.append(SRC_DIR)
|
||||
|
||||
from app.db.session import Base # noqa
|
||||
from app import models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
22
services/chat/alembic/script.py.mako
Normal file
22
services/chat/alembic/script.py.mako
Normal file
@@ -0,0 +1,22 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
56
services/chat/alembic/versions/8cc8115aaf0e_init.py
Normal file
56
services/chat/alembic/versions/8cc8115aaf0e_init.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""init
|
||||
|
||||
Revision ID: 8cc8115aaf0e
|
||||
Revises:
|
||||
Create Date: 2025-08-08 11:20:07.718286+00:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '8cc8115aaf0e'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('chat_messages',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('room_id', sa.UUID(), nullable=False),
|
||||
sa.Column('sender_id', sa.UUID(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_chat_messages_room_id'), 'chat_messages', ['room_id'], unique=False)
|
||||
op.create_index(op.f('ix_chat_messages_sender_id'), 'chat_messages', ['sender_id'], unique=False)
|
||||
op.create_table('chat_participants',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('room_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('is_admin', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_chat_participants_room_id'), 'chat_participants', ['room_id'], unique=False)
|
||||
op.create_index(op.f('ix_chat_participants_user_id'), 'chat_participants', ['user_id'], unique=False)
|
||||
op.create_table('chat_rooms',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('chat_rooms')
|
||||
op.drop_index(op.f('ix_chat_participants_user_id'), table_name='chat_participants')
|
||||
op.drop_index(op.f('ix_chat_participants_room_id'), table_name='chat_participants')
|
||||
op.drop_table('chat_participants')
|
||||
op.drop_index(op.f('ix_chat_messages_sender_id'), table_name='chat_messages')
|
||||
op.drop_index(op.f('ix_chat_messages_room_id'), table_name='chat_messages')
|
||||
op.drop_table('chat_messages')
|
||||
# ### end Alembic commands ###
|
||||
@@ -8,3 +8,4 @@ pydantic-settings
|
||||
python-dotenv
|
||||
httpx>=0.27
|
||||
pytest
|
||||
PyJWT>=2.8
|
||||
|
||||
46
services/chat/src/app/api/routes/chat.py
Normal file
46
services/chat/src/app/api/routes/chat.py
Normal file
@@ -0,0 +1,46 @@
|
||||
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)
|
||||
40
services/chat/src/app/core/security.py
Normal file
40
services/chat/src/app/core/security.py
Normal file
@@ -0,0 +1,40 @@
|
||||
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
|
||||
@@ -1,5 +1,6 @@
|
||||
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")
|
||||
|
||||
@@ -7,5 +8,5 @@ app = FastAPI(title="CHAT Service")
|
||||
def health():
|
||||
return {"status": "ok", "service": "chat"}
|
||||
|
||||
# v1 API
|
||||
app.include_router(ping_router, prefix="/v1")
|
||||
app.include_router(chat_router)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .chat import ChatRoom, ChatParticipant, Message # noqa
|
||||
|
||||
30
services/chat/src/app/models/chat.py
Normal file
30
services/chat/src/app/models/chat.py
Normal file
@@ -0,0 +1,30 @@
|
||||
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)
|
||||
45
services/chat/src/app/repositories/chat_repository.py
Normal file
45
services/chat/src/app/repositories/chat_repository.py
Normal file
@@ -0,0 +1,45 @@
|
||||
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()
|
||||
22
services/chat/src/app/schemas/chat.py
Normal file
22
services/chat/src/app/schemas/chat.py
Normal file
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
31
services/chat/src/app/services/chat_service.py
Normal file
31
services/chat/src/app/services/chat_service.py
Normal file
@@ -0,0 +1,31 @@
|
||||
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)
|
||||
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
|
||||
sys.path.append(SRC_DIR)
|
||||
|
||||
from app.db.session import Base # noqa
|
||||
from app import models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
22
services/match/alembic/script.py.mako
Normal file
22
services/match/alembic/script.py.mako
Normal file
@@ -0,0 +1,22 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
41
services/match/alembic/versions/00ce87deada6_init.py
Normal file
41
services/match/alembic/versions/00ce87deada6_init.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""init
|
||||
|
||||
Revision ID: 00ce87deada6
|
||||
Revises:
|
||||
Create Date: 2025-08-08 11:20:06.424809+00:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '00ce87deada6'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('match_pairs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id_a', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id_b', sa.UUID(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('score', sa.Float(), nullable=True),
|
||||
sa.Column('notes', sa.String(length=1000), nullable=True),
|
||||
sa.Column('created_by', sa.UUID(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_match_pairs_user_id_a'), 'match_pairs', ['user_id_a'], unique=False)
|
||||
op.create_index(op.f('ix_match_pairs_user_id_b'), 'match_pairs', ['user_id_b'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_match_pairs_user_id_b'), table_name='match_pairs')
|
||||
op.drop_index(op.f('ix_match_pairs_user_id_a'), table_name='match_pairs')
|
||||
op.drop_table('match_pairs')
|
||||
# ### end Alembic commands ###
|
||||
@@ -8,3 +8,4 @@ pydantic-settings
|
||||
python-dotenv
|
||||
httpx>=0.27
|
||||
pytest
|
||||
PyJWT>=2.8
|
||||
|
||||
70
services/match/src/app/api/routes/pairs.py
Normal file
70
services/match/src/app/api/routes/pairs.py
Normal file
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
40
services/match/src/app/core/security.py
Normal file
40
services/match/src/app/core/security.py
Normal file
@@ -0,0 +1,40 @@
|
||||
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
|
||||
@@ -1,5 +1,6 @@
|
||||
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")
|
||||
|
||||
@@ -7,5 +8,5 @@ app = FastAPI(title="MATCH Service")
|
||||
def health():
|
||||
return {"status": "ok", "service": "match"}
|
||||
|
||||
# v1 API
|
||||
app.include_router(ping_router, prefix="/v1")
|
||||
app.include_router(pairs_router)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .pair import MatchPair # noqa
|
||||
|
||||
22
services/match/src/app/models/pair.py
Normal file
22
services/match/src/app/models/pair.py
Normal file
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
43
services/match/src/app/repositories/pair_repository.py
Normal file
43
services/match/src/app/repositories/pair_repository.py
Normal file
@@ -0,0 +1,43 @@
|
||||
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()
|
||||
22
services/match/src/app/schemas/pair.py
Normal file
22
services/match/src/app/schemas/pair.py
Normal file
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
27
services/match/src/app/services/pair_service.py
Normal file
27
services/match/src/app/services/pair_service.py
Normal file
@@ -0,0 +1,27 @@
|
||||
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)
|
||||
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
|
||||
sys.path.append(SRC_DIR)
|
||||
|
||||
from app.db.session import Base # noqa
|
||||
from app import models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
22
services/payments/alembic/script.py.mako
Normal file
22
services/payments/alembic/script.py.mako
Normal file
@@ -0,0 +1,22 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
38
services/payments/alembic/versions/6641523a6967_init.py
Normal file
38
services/payments/alembic/versions/6641523a6967_init.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""init
|
||||
|
||||
Revision ID: 6641523a6967
|
||||
Revises:
|
||||
Create Date: 2025-08-08 11:20:09.064584+00:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6641523a6967'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('invoices',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('client_id', sa.UUID(), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('currency', sa.String(length=3), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('description', sa.String(length=500), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_invoices_client_id'), 'invoices', ['client_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_invoices_client_id'), table_name='invoices')
|
||||
op.drop_table('invoices')
|
||||
# ### end Alembic commands ###
|
||||
@@ -8,3 +8,4 @@ pydantic-settings
|
||||
python-dotenv
|
||||
httpx>=0.27
|
||||
pytest
|
||||
PyJWT>=2.8
|
||||
|
||||
62
services/payments/src/app/api/routes/payments.py
Normal file
62
services/payments/src/app/api/routes/payments.py
Normal file
@@ -0,0 +1,62 @@
|
||||
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)
|
||||
40
services/payments/src/app/core/security.py
Normal file
40
services/payments/src/app/core/security.py
Normal file
@@ -0,0 +1,40 @@
|
||||
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
|
||||
@@ -1,5 +1,6 @@
|
||||
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")
|
||||
|
||||
@@ -7,5 +8,5 @@ app = FastAPI(title="PAYMENTS Service")
|
||||
def health():
|
||||
return {"status": "ok", "service": "payments"}
|
||||
|
||||
# v1 API
|
||||
app.include_router(ping_router, prefix="/v1")
|
||||
app.include_router(payments_router)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .payment import Invoice # noqa
|
||||
|
||||
20
services/payments/src/app/models/payment.py
Normal file
20
services/payments/src/app/models/payment.py
Normal file
@@ -0,0 +1,20 @@
|
||||
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)
|
||||
43
services/payments/src/app/repositories/payment_repository.py
Normal file
43
services/payments/src/app/repositories/payment_repository.py
Normal file
@@ -0,0 +1,43 @@
|
||||
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()
|
||||
24
services/payments/src/app/schemas/payment.py
Normal file
24
services/payments/src/app/schemas/payment.py
Normal file
@@ -0,0 +1,24 @@
|
||||
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)
|
||||
27
services/payments/src/app/services/payment_service.py
Normal file
27
services/payments/src/app/services/payment_service.py
Normal file
@@ -0,0 +1,27 @@
|
||||
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")
|
||||
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
|
||||
sys.path.append(SRC_DIR)
|
||||
|
||||
from app.db.session import Base # noqa
|
||||
from app import models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
22
services/profiles/alembic/script.py.mako
Normal file
22
services/profiles/alembic/script.py.mako
Normal file
@@ -0,0 +1,22 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,26 @@
|
||||
"""add FK photos.profile_id -> profiles.id
|
||||
|
||||
Revision ID: 5c69d1403313
|
||||
Revises: 769f535c9249
|
||||
Create Date: 2025-08-08 11:43:53.014776+00:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '5c69d1403313'
|
||||
down_revision = '769f535c9249'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_foreign_key(None, 'photos', 'profiles', ['profile_id'], ['id'], ondelete='CASCADE')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'photos', type_='foreignkey')
|
||||
# ### end Alembic commands ###
|
||||
55
services/profiles/alembic/versions/769f535c9249_init.py
Normal file
55
services/profiles/alembic/versions/769f535c9249_init.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""init
|
||||
|
||||
Revision ID: 769f535c9249
|
||||
Revises:
|
||||
Create Date: 2025-08-08 11:20:05.142049+00:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '769f535c9249'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('photos',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('profile_id', sa.UUID(), nullable=False),
|
||||
sa.Column('url', sa.String(length=500), nullable=False),
|
||||
sa.Column('is_main', sa.Boolean(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_photos_profile_id'), 'photos', ['profile_id'], unique=False)
|
||||
op.create_table('profiles',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('gender', sa.String(length=16), nullable=False),
|
||||
sa.Column('birthdate', sa.Date(), nullable=True),
|
||||
sa.Column('city', sa.String(length=120), nullable=True),
|
||||
sa.Column('bio', sa.Text(), nullable=True),
|
||||
sa.Column('languages', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('interests', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('preferences', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('verification_status', sa.String(length=16), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_profiles_user_id'), 'profiles', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_profiles_user_id'), table_name='profiles')
|
||||
op.drop_table('profiles')
|
||||
op.drop_index(op.f('ix_photos_profile_id'), table_name='photos')
|
||||
op.drop_table('photos')
|
||||
# ### end Alembic commands ###
|
||||
@@ -3,4 +3,4 @@ set -e
|
||||
# Run migrations (no-op if no revisions yet)
|
||||
alembic -c alembic.ini upgrade head || true
|
||||
# Start app
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-level debug
|
||||
|
||||
@@ -8,3 +8,4 @@ pydantic-settings
|
||||
python-dotenv
|
||||
httpx>=0.27
|
||||
pytest
|
||||
PyJWT>=2.8
|
||||
|
||||
31
services/profiles/src/app/api/routes/profiles.py
Normal file
31
services/profiles/src/app/api/routes/profiles.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.deps import get_db
|
||||
from app.core.security import get_current_user, JwtUser
|
||||
from app.schemas.profile import ProfileCreate, ProfileOut
|
||||
from app.repositories.profile_repository import ProfileRepository
|
||||
from app.services.profile_service import ProfileService
|
||||
|
||||
# отключаем авто-редирект /path -> /path/
|
||||
router = APIRouter(prefix="/v1/profiles", tags=["profiles"], redirect_slashes=False)
|
||||
|
||||
@router.get("/me", response_model=ProfileOut)
|
||||
def get_my_profile(current: JwtUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
svc = ProfileService(ProfileRepository(db))
|
||||
p = svc.get_by_user(current.sub)
|
||||
if not p:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||
return p
|
||||
|
||||
@router.post("", response_model=ProfileOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_my_profile(payload: ProfileCreate,
|
||||
current: JwtUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
svc = ProfileService(ProfileRepository(db))
|
||||
existing = svc.get_by_user(current.sub)
|
||||
if existing:
|
||||
# если хотите строго — верните 409; оставлю 200/201 для удобства e2e
|
||||
return existing
|
||||
return svc.create(current.sub, payload)
|
||||
59
services/profiles/src/app/core/security.py
Normal file
59
services/profiles/src/app/core/security.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel
|
||||
|
||||
reusable_bearer = HTTPBearer(auto_error=True)
|
||||
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret")
|
||||
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
||||
|
||||
# Возможность включить строгую проверку audience/issuer в будущем
|
||||
JWT_VERIFY_AUD = os.getenv("JWT_VERIFY_AUD", "0") == "1"
|
||||
JWT_AUDIENCE: Optional[str] = os.getenv("JWT_AUDIENCE") or None
|
||||
JWT_VERIFY_ISS = os.getenv("JWT_VERIFY_ISS", "0") == "1"
|
||||
JWT_ISSUER: Optional[str] = os.getenv("JWT_ISSUER") or None
|
||||
|
||||
# Допустимая рассинхронизация часов (сек)
|
||||
JWT_LEEWAY = int(os.getenv("JWT_LEEWAY", "30"))
|
||||
|
||||
class JwtUser(BaseModel):
|
||||
sub: str
|
||||
email: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
|
||||
def decode_token(token: str) -> JwtUser:
|
||||
options = {
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_aud": JWT_VERIFY_AUD,
|
||||
"verify_iss": JWT_VERIFY_ISS,
|
||||
}
|
||||
kwargs = {"algorithms": [JWT_ALGORITHM], "options": options, "leeway": JWT_LEEWAY}
|
||||
if JWT_VERIFY_AUD and JWT_AUDIENCE:
|
||||
kwargs["audience"] = JWT_AUDIENCE
|
||||
if JWT_VERIFY_ISS and JWT_ISSUER:
|
||||
kwargs["issuer"] = JWT_ISSUER
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, **kwargs)
|
||||
sub = str(payload.get("sub") or "")
|
||||
if not sub:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: no sub")
|
||||
return JwtUser(sub=sub, email=payload.get("email"), role=payload.get("role"))
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||
except jwt.InvalidAudienceError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid audience")
|
||||
except jwt.InvalidIssuerError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid issuer")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(reusable_bearer)) -> JwtUser:
|
||||
if credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth scheme")
|
||||
return decode_token(credentials.credentials)
|
||||
10
services/profiles/src/app/db/deps.py
Normal file
10
services/profiles/src/app/db/deps.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from typing import Generator
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.session import SessionLocal # должен существовать в проекте
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,5 +1,6 @@
|
||||
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")
|
||||
|
||||
@@ -7,5 +8,5 @@ app = FastAPI(title="PROFILES Service")
|
||||
def health():
|
||||
return {"status": "ok", "service": "profiles"}
|
||||
|
||||
# v1 API
|
||||
app.include_router(ping_router, prefix="/v1")
|
||||
app.include_router(profiles_router)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from .profile import Profile # noqa
|
||||
from .photo import Photo # noqa
|
||||
|
||||
27
services/profiles/src/app/models/photo.py
Normal file
27
services/profiles/src/app/models/photo.py
Normal file
@@ -0,0 +1,27 @@
|
||||
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),
|
||||
ForeignKey("profiles.id", ondelete="CASCADE"),
|
||||
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")
|
||||
29
services/profiles/src/app/models/profile.py
Normal file
29
services/profiles/src/app/models/profile.py
Normal file
@@ -0,0 +1,29 @@
|
||||
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 typing import Optional
|
||||
|
||||
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[Optional[list[str]]] = mapped_column(JSONB, default=list)
|
||||
interests: Mapped[Optional[list[str]]] = 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")
|
||||
26
services/profiles/src/app/repositories/profile_repository.py
Normal file
26
services/profiles/src/app/repositories/profile_repository.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
from app.models.profile import Profile
|
||||
from app.schemas.profile import ProfileCreate
|
||||
|
||||
class ProfileRepository:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_by_user(self, user_id: UUID) -> Optional[Profile]:
|
||||
return self.db.execute(select(Profile).where(Profile.user_id == user_id)).scalar_one_or_none()
|
||||
|
||||
def create(self, user_id: UUID, data: ProfileCreate) -> Profile:
|
||||
p = Profile(
|
||||
user_id=user_id,
|
||||
gender=data.gender,
|
||||
city=data.city,
|
||||
languages=list(data.languages or []),
|
||||
interests=list(data.interests or []),
|
||||
)
|
||||
self.db.add(p)
|
||||
self.db.commit()
|
||||
self.db.refresh(p)
|
||||
return p
|
||||
32
services/profiles/src/app/schemas/profile.py
Normal file
32
services/profiles/src/app/schemas/profile.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
try:
|
||||
# Pydantic v2
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
_V2 = True
|
||||
except Exception:
|
||||
# Pydantic v1 fallback
|
||||
from pydantic import BaseModel, Field
|
||||
ConfigDict = None
|
||||
_V2 = False
|
||||
|
||||
class ProfileBase(BaseModel):
|
||||
gender: str
|
||||
city: str
|
||||
languages: List[str] = Field(default_factory=list)
|
||||
interests: List[str] = Field(default_factory=list)
|
||||
|
||||
class ProfileCreate(ProfileBase):
|
||||
pass
|
||||
|
||||
class ProfileOut(ProfileBase):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
|
||||
if _V2:
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
else:
|
||||
class Config:
|
||||
orm_mode = True
|
||||
13
services/profiles/src/app/services/profile_service.py
Normal file
13
services/profiles/src/app/services/profile_service.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from uuid import UUID
|
||||
from app.schemas.profile import ProfileCreate
|
||||
from app.repositories.profile_repository import ProfileRepository
|
||||
|
||||
class ProfileService:
|
||||
def __init__(self, repo: ProfileRepository):
|
||||
self.repo = repo
|
||||
|
||||
def get_by_user(self, user_id: UUID):
|
||||
return self.repo.get_by_user(user_id)
|
||||
|
||||
def create(self, user_id: UUID, data: ProfileCreate):
|
||||
return self.repo.create(user_id, data)
|
||||
Reference in New Issue
Block a user