api development
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-08-08 21:58:36 +09:00
parent d58302c2c8
commit cc87dcc0fa
157 changed files with 14629 additions and 7 deletions

View File

@@ -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

View 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"}

View 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 ###

View File

@@ -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

View 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

View 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)

View 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)

View 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

View File

@@ -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)

View File

@@ -0,0 +1 @@
from .user import User, Role # noqa: F401

View 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)

View 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()

View 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"

View 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

View File

@@ -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

View 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"}

View 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 ###

View File

@@ -8,3 +8,4 @@ pydantic-settings
python-dotenv
httpx>=0.27
pytest
PyJWT>=2.8

View 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)

View 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

View File

@@ -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)

View File

@@ -0,0 +1 @@
from .chat import ChatRoom, ChatParticipant, Message # noqa

View 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)

View 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()

View 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)

View 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)

View File

@@ -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

View 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"}

View 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 ###

View File

@@ -8,3 +8,4 @@ pydantic-settings
python-dotenv
httpx>=0.27
pytest
PyJWT>=2.8

View 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)

View 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

View File

@@ -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)

View File

@@ -0,0 +1 @@
from .pair import MatchPair # noqa

View 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)

View 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()

View 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)

View 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)

View File

@@ -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

View 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"}

View 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 ###

View File

@@ -8,3 +8,4 @@ pydantic-settings
python-dotenv
httpx>=0.27
pytest
PyJWT>=2.8

View 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)

View 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

View File

@@ -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)

View File

@@ -0,0 +1 @@
from .payment import Invoice # noqa

View 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)

View 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()

View 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)

View 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")

View File

@@ -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

View 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"}

View File

@@ -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 ###

View 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 ###

View File

@@ -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

View File

@@ -8,3 +8,4 @@ pydantic-settings
python-dotenv
httpx>=0.27
pytest
PyJWT>=2.8

View 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)

View 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)

View 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()

View File

@@ -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)

View File

@@ -0,0 +1,2 @@
from .profile import Profile # noqa
from .photo import Photo # noqa

View 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")

View 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")

View 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

View 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

View 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)