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