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
5 changes: 5 additions & 0 deletions app/core/database.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from collections.abc import AsyncGenerator
from typing import Annotated

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase

Expand All @@ -25,3 +27,6 @@ class Base(DeclarativeBase):
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
yield session


SessionDep = Annotated[AsyncSession, Depends(get_session)]
25 changes: 23 additions & 2 deletions app/core/security.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
import asyncio
from datetime import UTC, datetime, timedelta
from typing import Any
from typing import Annotated, Any

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

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

pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
header_scheme = APIKeyHeader(name='X-API-Key', auto_error=False)


async def get_b2b_partner_by_api_key(
api_key: Annotated[str | None, Depends(header_scheme)], session: SessionDep
) -> User:
from app.services.user.service import UserService

if not api_key:
raise CredentialsError('API key is required')
key_obj = await UserService.authenticate_api_key_b2b_partner(session, api_key)
if not key_obj:
raise CredentialsError('Invalid API key')
user = key_obj.user
if not user.is_active:
raise CredentialsError('User is not active')
if user.role not in (UserRole.USER_B2B, UserRole.SELLER_B2B):
raise CredentialsError('Not a B2B partner account')
return user


def verify_password_sync(plain_password: str, hashed_password: str) -> bool:
Expand Down
3 changes: 0 additions & 3 deletions app/services/inventory/deps.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
from typing import Annotated

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_session
from app.services.inventory.service import InventoryAdminService, InventoryService


Expand All @@ -19,4 +17,3 @@ async def get_inventory_admin_service() -> InventoryAdminService:
InventoryAdminServiceDep = Annotated[
InventoryAdminService, Depends(get_inventory_admin_service)
]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
27 changes: 13 additions & 14 deletions app/services/inventory/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
from uuid import UUID

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

from app.core.database import get_session
from app.core.database import SessionDep
from app.core.security import RoleChecker, UserRole
from app.services.inventory.models import ProductStatus
from app.services.inventory.rate_limit import check_rate_limit
Expand Down Expand Up @@ -52,7 +51,7 @@

@router_v1.get('/', response_model=list[ProductRead])
async def get_active_products(
session: Annotated[AsyncSession, Depends(get_session)],
session: SessionDep,
service: Annotated[InventoryService, Depends(get_inventory_service)],
skip: int = 0,
limit: int = 50,
Expand All @@ -69,7 +68,7 @@ async def get_active_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)],
session: SessionDep,
current_user: Annotated[User, SELLER_DEPENDENCY],
service: Annotated[InventoryService, Depends(get_inventory_service)],
) -> ProductRead:
Expand All @@ -85,7 +84,7 @@ async def create_product(
@router_v1.patch('/{product_id}/activate', response_model=ProductRead)
async def activate_product(
product_id: UUID,
session: Annotated[AsyncSession, Depends(get_session)],
session: SessionDep,
current_user: Annotated[User, ADMIN_DEPENDENCY],
service: Annotated[InventoryAdminService, Depends(get_inventory_admin_service)],
) -> ProductRead:
Expand All @@ -102,7 +101,7 @@ async def activate_product(
async def update_product(
product_id: UUID,
product_data: ProductUpdate,
session: Annotated[AsyncSession, Depends(get_session)],
session: SessionDep,
current_user: Annotated[User, ADMIN_AND_SELLER_DEPENDENCY],
service: Annotated[InventoryService, Depends(get_inventory_service)],
) -> ProductRead:
Expand All @@ -118,7 +117,7 @@ async def update_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)],
session: SessionDep,
current_user: Annotated[User, ADMIN_DEPENDENCY],
service: Annotated[InventoryService, Depends(get_inventory_service)],
) -> None:
Expand All @@ -132,7 +131,7 @@ async def delete_product(
@router_v1.get('/{product_id}', response_model=ProductRead)
async def get_product(
product_id: UUID,
session: Annotated[AsyncSession, Depends(get_session)],
session: SessionDep,
service: Annotated[InventoryService, Depends(get_inventory_service)],
) -> ProductRead:
product = await service.get_product(
Expand All @@ -147,10 +146,10 @@ async def get_product(
async def reservation_data(
request: Request,
reservation_data: ReservationCreate,
session: SessionDep,
current_user: Annotated[User, Depends(get_current_user)],
service: Annotated[InventoryService, Depends(get_inventory_service)],
x_idempotency_key: str = Header(...),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> ReservationResponse:
await check_rate_limit(
rate_limit_script=request.app.state.rate_limit_script,
Expand All @@ -169,7 +168,7 @@ async def reservation_data(
@router_v1.post('/{product_id}/submit', response_model=ProductRead)
async def submit_for_moderation(
product_id: UUID,
session: Annotated[AsyncSession, Depends(get_session)],
session: SessionDep,
current_user: Annotated[User, SELLER_DEPENDENCY],
service: Annotated[InventoryService, Depends(get_inventory_service)],
) -> ProductRead:
Expand All @@ -184,7 +183,7 @@ async def submit_for_moderation(
@router_v1.post('/{product_id}/approve', response_model=ProductRead)
async def approve_product(
product_id: UUID,
session: Annotated[AsyncSession, Depends(get_session)],
session: SessionDep,
current_user: Annotated[User, ADMIN_DEPENDENCY],
service: Annotated[InventoryAdminService, Depends(get_inventory_admin_service)],
) -> ProductRead:
Expand All @@ -199,7 +198,7 @@ async def approve_product(
@router_v1.post('/{product_id}/reject', response_model=ProductRead)
async def reject_product(
product_id: UUID,
session: Annotated[AsyncSession, Depends(get_session)],
session: SessionDep,
current_user: Annotated[User, ADMIN_DEPENDENCY],
service: Annotated[InventoryAdminService, Depends(get_inventory_admin_service)],
reason: str = Query(...),
Expand All @@ -216,7 +215,7 @@ async def reject_product(
@router_v1.post('/{product_id}/claim', response_model=ProductRead)
async def claim_for_moderation(
product_id: UUID,
session: Annotated[AsyncSession, Depends(get_session)],
session: SessionDep,
current_user: Annotated[User, ADMIN_DEPENDENCY],
service: Annotated[InventoryAdminService, Depends(get_inventory_admin_service)],
) -> ProductRead:
Expand Down
19 changes: 19 additions & 0 deletions app/services/user/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ class User(Base):
role: Mapped[UserRole] = mapped_column(SQLEnum(UserRole), default=UserRole.USER)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
refresh_tokens: Mapped[list['RefreshToken']] = relationship(back_populates='user')
api_keys_b2b_partners: Mapped[list['APIKeyB2BPartner']] = relationship(
back_populates='user'
)


class RefreshToken(Base):
Expand All @@ -48,3 +51,19 @@ class RefreshToken(Base):
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
user: Mapped[User] = relationship(back_populates='refresh_tokens')


class APIKeyB2BPartner(Base):
__tablename__ = 'api_keys_b2b_partners'
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
user_id: Mapped[UUID] = mapped_column(
ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
key_prefix: Mapped[str] = mapped_column(String(12), nullable=False, index=True)
hashed_key: Mapped[str] = mapped_column(String(), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
user: Mapped[User] = relationship(back_populates='api_keys_b2b_partners')
64 changes: 54 additions & 10 deletions app/services/user/routes.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
from http import HTTPStatus
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_session
from app.core.database import SessionDep
from app.core.exceptions import CredentialsError
from app.core.security import create_access_token
from app.services.user.models import User
from app.services.user.schemas import RefreshTokenRequest, Token, UserCreate, UserRead
from app.core.security import RoleChecker, create_access_token
from app.services.user.models import User, UserRole
from app.services.user.schemas import (
APIKeyCreate,
APIKeyRead,
APIKeyWithSecret,
RefreshTokenRequest,
Token,
UserCreate,
UserRead,
)
from app.services.user.service import UserService
from app.shared.deps import get_current_user

router_v1 = APIRouter()
B2B_PARTNER_DEP = Depends(RoleChecker([UserRole.USER_B2B, UserRole.SELLER_B2B]))


@router_v1.post('/users', status_code=status.HTTP_201_CREATED)
async def create_user(
user_create: UserCreate, session: Annotated[AsyncSession, Depends(get_session)]
) -> UserRead:
async def create_user(user_create: UserCreate, session: SessionDep) -> UserRead:
user = await UserService.create_user(session, user_create)
return UserRead.model_validate(user)


@router_v1.post('/auth/token')
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
session: Annotated[AsyncSession, Depends(get_session)],
session: SessionDep,
) -> Token:
user = await UserService.authenticate_user(
session, form_data.username, form_data.password
Expand All @@ -43,7 +51,7 @@ async def login(
@router_v1.post('/auth/refresh')
async def refresh_token(
request_data: RefreshTokenRequest,
session: Annotated[AsyncSession, Depends(get_session)],
session: SessionDep,
) -> Token:
user = await UserService.refresh_access_token(session, request_data.refresh_token)
access_token = create_access_token(data={'sub': str(user.email)})
Expand All @@ -58,3 +66,39 @@ async def read_user_me(
current_user: Annotated[User, Depends(get_current_user)],
) -> UserRead:
return UserRead.model_validate(current_user)


@router_v1.post(
'/users/me/api-keys',
response_model=APIKeyWithSecret,
status_code=HTTPStatus.CREATED,
)
async def create_api_key_b2b_partner(
api_key_create: APIKeyCreate,
current_user: Annotated[User, B2B_PARTNER_DEP],
session: SessionDep,
) -> APIKeyWithSecret:
api_key, raw_key = await UserService.create_api_key_b2b_partner(
session, current_user.id, api_key_create.name
)
data = APIKeyRead.model_validate(api_key).model_dump()
return APIKeyWithSecret(**data, raw_key=raw_key)


@router_v1.get('/users/me/api-keys', response_model=list[APIKeyRead])
async def get_api_keys_b2b_partners(
current_user: Annotated[User, B2B_PARTNER_DEP],
) -> list[APIKeyRead]:
return [
APIKeyRead.model_validate(api_key)
for api_key in current_user.api_keys_b2b_partners
]


@router_v1.delete('/users/me/api-keys/{key_id}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_api_key_b2b_partner(
key_id: UUID,
current_user: Annotated[User, B2B_PARTNER_DEP],
session: SessionDep,
) -> None:
await UserService.delete_api_key_b2b_partner(session, current_user.id, key_id)
20 changes: 20 additions & 0 deletions app/services/user/schemas.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from uuid import UUID

from pydantic import BaseModel, ConfigDict, EmailStr
Expand Down Expand Up @@ -25,3 +26,22 @@ class Token(BaseModel):

class RefreshTokenRequest(BaseModel):
refresh_token: str


class APIKeyCreate(BaseModel):
name: str


class APIKeyRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
key_prefix: str
is_active: bool
created_at: datetime
expires_at: datetime | None = None
last_used_at: datetime | None = None


class APIKeyWithSecret(APIKeyRead):
raw_key: str
Loading