Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ jobs:
# =====
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Set up UV (without pip)
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
with:
enable-cache: true

Expand Down
10 changes: 10 additions & 0 deletions app/core/exception_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
CredentialsError,
InsufficientInventoryError,
NotFoundError,
PermissionDeniedError,
UserAlreadyExists,
)

Expand Down Expand Up @@ -56,3 +57,12 @@ async def conflict_error_handler(request: Request, exc: ConflictError) -> JSONRe
status_code=status.HTTP_409_CONFLICT,
content={'detail': str(exc) or CONFLICT_MESSAGE},
)


async def permission_denied_handler(
request: Request, exc: PermissionDeniedError
) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={'detail': str(exc) or 'Permission denied'},
)
7 changes: 7 additions & 0 deletions app/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,10 @@ class InsufficientInventoryError(AppError):

def __init__(self, message: str = 'Insufficient inventory'):
super().__init__(message=message)


class PermissionDeniedError(AppError):
"""Permission denied."""

def __init__(self, message: str = 'Permission denied'):
super().__init__(message=message)
37 changes: 37 additions & 0 deletions app/core/security.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import asyncio
from datetime import UTC, datetime, timedelta
from typing import Any

from fastapi import Depends
from jose import jwt
from passlib.context import CryptContext

from app.core.config import settings
from app.core.exceptions import PermissionDeniedError
from app.services.user.models import User, UserRole
from app.shared.deps import get_current_user

pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')

Expand Down Expand Up @@ -40,3 +45,35 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
to_encode, settings.secret_key, algorithm=settings.jwt_algorithm
)
return str(encoded_jwt)


async def check_permission(
user: User, allowed_roles: list[UserRole], required_verified: bool = False
) -> None:
if user.role == UserRole.ADMIN:
return
if required_verified and not user.is_verified:
raise PermissionDeniedError('User is not verified')
if user.role not in allowed_roles:
raise PermissionDeniedError(
'User does not have permission to perform this action'
)


def check_ownership(user: User, obj: Any) -> None:
if user.role in (UserRole.ADMIN, UserRole.MODERATOR):
return
if not hasattr(obj, 'owner_id'):
raise ValueError(f'Object {type(obj)} does not have owner_id')
if obj.owner_id != user.id:
raise PermissionDeniedError


class RoleChecker:
def __init__(self, allowed_roles: list[UserRole], required_verified: bool = False):
self.allowed_roles = allowed_roles
self.required_verified = required_verified

async def __call__(self, user: User = Depends(get_current_user)) -> User:
await check_permission(user, self.allowed_roles, self.required_verified)
return user
6 changes: 6 additions & 0 deletions app/core/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
credentials_error_handler,
insufficient_inventory_error_handler,
not_found_error_handler,
permission_denied_handler,
user_already_exists_handler,
)
from .exceptions import (
ConflictError,
CredentialsError,
InsufficientInventoryError,
NotFoundError,
PermissionDeniedError,
UserAlreadyExists,
)

Expand All @@ -25,3 +27,7 @@ def setup_exception_handlers(app: FastAPI) -> None:
InsufficientInventoryError,
insufficient_inventory_error_handler, # type: ignore[arg-type]
)
app.add_exception_handler(
PermissionDeniedError,
permission_denied_handler, # type: ignore[arg-type]
)
14 changes: 14 additions & 0 deletions app/services/inventory/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
from uuid import UUID, uuid4

from sqlalchemy import CheckConstraint, ForeignKey, Numeric
from sqlalchemy import Enum as SQLEnum
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from sqlalchemy.types import DateTime, Integer, String, Text
Expand All @@ -13,10 +15,19 @@
DECIMAL_SCALE = 2


class ProductStatus(StrEnum):
DRAFT = 'DRAFT'
ACTIVE = 'ACTIVE'
ARCHIVED = 'ARCHIVED'


class Product(Base):
__tablename__ = 'products'

id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
owner_id: Mapped[UUID] = mapped_column(
ForeignKey('users.id'), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(), nullable=False)
description: Mapped[str | None] = mapped_column(Text(), nullable=True)
price: Mapped[Decimal] = mapped_column(
Expand All @@ -31,6 +42,9 @@ class Product(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
status: Mapped[ProductStatus] = mapped_column(
SQLEnum(ProductStatus), nullable=False, default=ProductStatus.DRAFT
)

__table_args__ = (
CheckConstraint('qty_available >= 0', name='check_qty_non_negative'),
Expand Down
125 changes: 121 additions & 4 deletions app/services/inventory/routes.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,133 @@
from fastapi import APIRouter, Depends, Header, Request
from typing import Annotated, Any
from uuid import UUID

from fastapi import APIRouter, Depends, Header, Request, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_session
from app.core.security import RoleChecker, UserRole
from app.services.inventory.models import ProductStatus
from app.services.inventory.rate_limit import check_rate_limit
from app.services.inventory.schemas import ReservationCreate, ReservationResponse
from app.services.inventory.service import reserve_items
from app.services.inventory.schemas import (
ProductCreate,
ProductRead,
ProductUpdate,
ReservationCreate,
ReservationResponse,
)
from app.services.inventory.service import InventoryService
from app.services.user.models import User
from app.shared.decorators import idempotent
from app.shared.deps import get_current_user

router_v1 = APIRouter(prefix='/inventory', tags=['Inventory'])

SELLER_DEPENDENCY = Depends(
RoleChecker(
allowed_roles=[UserRole.SELLER, UserRole.SELLER_B2B],
required_verified=True,
)
)
ADMIN_DEPENDENCY = Depends(
RoleChecker(
allowed_roles=[UserRole.ADMIN, UserRole.MODERATOR],
)
)
ADMIN_AND_SELLER_DEPENDENCY = Depends(
RoleChecker(
allowed_roles=[
UserRole.ADMIN,
UserRole.MODERATOR,
UserRole.SELLER,
UserRole.SELLER_B2B,
],
)
)


@router_v1.get('/', response_model=list[ProductRead])
async def get_active_products(
session: Annotated[AsyncSession, Depends(get_session)],
skip: int = 0,
limit: int = 50,
) -> list[ProductRead]:
products = await InventoryService.get_products(
status=ProductStatus.ACTIVE,
skip=skip,
limit=limit,
session=session,
)
return [ProductRead.model_validate(p) for p in products]


@router_v1.post('/', response_model=ProductRead, status_code=status.HTTP_201_CREATED)
async def create_product(
product_data: ProductCreate,
session: Annotated[AsyncSession, Depends(get_session)],
current_user: Annotated[User, SELLER_DEPENDENCY],
) -> ProductRead:
product = await InventoryService.create_product(
session=session,
product_data=product_data,
owner_id=current_user.id,
)
return ProductRead.model_validate(product)


@router_v1.patch('/{product_id}/activate', response_model=ProductRead)
async def activate_product(
product_id: UUID,
session: Annotated[AsyncSession, Depends(get_session)],
_: Any = ADMIN_DEPENDENCY,
) -> ProductRead:
product = await InventoryService.change_status(
session=session,
product_id=product_id,
status=ProductStatus.ACTIVE,
)
return ProductRead.model_validate(product)


@router_v1.patch('/{product_id}', response_model=ProductRead)
async def update_product(
product_id: UUID,
product_data: ProductUpdate,
session: Annotated[AsyncSession, Depends(get_session)],
current_user: Annotated[User, ADMIN_AND_SELLER_DEPENDENCY],
) -> ProductRead:
product = await InventoryService.update_product(
session=session,
product_id=product_id,
product_data=product_data,
current_user=current_user,
)
return ProductRead.model_validate(product)


@router_v1.delete('/{product_id}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_product(
product_id: UUID,
session: Annotated[AsyncSession, Depends(get_session)],
current_user: Annotated[User, ADMIN_DEPENDENCY],
) -> None:
await InventoryService.delete_product(
session=session,
product_id=product_id,
current_user=current_user,
)


@router_v1.get('/{product_id}', response_model=ProductRead)
async def get_product(
product_id: UUID,
session: Annotated[AsyncSession, Depends(get_session)],
) -> ProductRead:
product = await InventoryService.get_product(
session=session,
product_id=product_id,
)
return ProductRead.model_validate(product)


@router_v1.post('/reserve', response_model=ReservationResponse)
@idempotent()
Expand All @@ -26,7 +143,7 @@ async def reservation_data(
user_id=str(current_user.id),
item_id=str(reservation_data.product_id),
)
result = await reserve_items(
result = await InventoryService.reserve_items(
session=session,
user_id=current_user.id,
idempotency_key=x_idempotency_key,
Expand Down
33 changes: 33 additions & 0 deletions app/services/inventory/schemas.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,41 @@
from datetime import datetime
from decimal import Decimal
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field

from app.services.inventory.models import ProductStatus


class ProductCreate(BaseModel):
name: str
description: str | None = None
price: Decimal = Field(gt=0, description='Price must be greater than 0')
qty_available: int = Field(
ge=0, description='Quantity must be greater than or equal to 0'
)


class ProductUpdate(BaseModel):
name: str | None = None
description: str | None = None
price: Decimal | None = Field(gt=0, description='Price must be greater than 0')
qty_available: int | None = Field(
ge=0, description='Quantity must be greater than or equal to 0'
)


class ProductRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
description: str
price: Decimal
qty_available: int
status: ProductStatus
created_at: datetime
updated_at: datetime


class ReservationCreate(BaseModel):
product_id: UUID
Expand Down
Loading