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