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/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)
|
||||
Reference in New Issue
Block a user