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
28 changes: 27 additions & 1 deletion app/core/admin/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,20 @@ def order_link_formatter(model: Any, name: Any) -> Any:
return 'N/A'
return Markup(f'<a href="/admin/order/details/{order_id}">{order_id}</a>')

@staticmethod
def docs_link_formatter(model: Any, name: Any) -> Any:
docs = getattr(model, name)
if not docs:
return 'No docs'
links = []
if isinstance(docs, dict):
for doc_type, s3_key in docs.items():
links.append(
f'<a href="/api/v1/media/view?key={s3_key}" '
f'target="_blank">{doc_type}</a>'
)
return Markup(', '.join(links))


class VerificationRequestAdmin(ModelView, model=VerificationRequest):
column_list = [
Expand All @@ -93,6 +107,7 @@ class VerificationRequestAdmin(ModelView, model=VerificationRequest):
'user_id': AdminPanelFormatter.user_link_formatter,
'target_role': AdminPanelFormatter.status_formatter,
'status': AdminPanelFormatter.status_formatter,
'docs_url': AdminPanelFormatter.docs_link_formatter,
}
column_formatters_detail = column_formatters
name = 'Verification Request'
Expand Down Expand Up @@ -156,6 +171,7 @@ class ProductAdmin(ModelView, model=Product):
Product.status,
Product.owner_id,
Product.moderator_id,
Product.moderation_comment,
]
column_labels = {'qty_available': 'Quantity Available'}
column_default_sort = [('created_at', True)]
Expand All @@ -165,11 +181,21 @@ class ProductAdmin(ModelView, model=Product):
'moderator_id': AdminPanelFormatter.user_link_formatter,
}
column_formatters_detail = column_formatters
column_searchable_list = [Product.id, Product.name, Product.description]
column_searchable_list = [
Product.id,
Product.name,
Product.description,
Product.moderation_comment,
]
can_delete = False
name = 'Product'
name_plural = 'Products'
icon = 'fa-solid fa-box'
form_columns = [
Product.status,
Product.moderator_id,
Product.moderation_comment,
]


class OrderAdmin(ModelView, model=Order):
Expand Down
4 changes: 4 additions & 0 deletions app/core/auth_schemes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/api/v1/auth/token')
header_scheme = APIKeyHeader(name='X-API-Key', auto_error=False)
23 changes: 23 additions & 0 deletions app/core/hashing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import asyncio

from passlib.context import CryptContext

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


def verify_password_sync(plain_password: str, hashed_password: str) -> bool:
return bool(pwd_context.verify(plain_password, hashed_password))


async def verify_password(plain_password: str, hashed_password: str) -> bool:
return await asyncio.to_thread(
verify_password_sync, plain_password, hashed_password
)


def get_password_hash_sync(password: str) -> str:
return str(pwd_context.hash(password))


async def get_password_hash(password: str) -> str:
return await asyncio.to_thread(get_password_hash_sync, password)
25 changes: 1 addition & 24 deletions app/core/security.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import asyncio
from datetime import UTC, datetime, timedelta
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.auth_schemes import header_scheme
from app.core.config import settings
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
Expand All @@ -35,24 +30,6 @@ async def get_b2b_partner_by_api_key(
return user


def verify_password_sync(plain_password: str, hashed_password: str) -> bool:
return bool(pwd_context.verify(plain_password, hashed_password))


async def verify_password(plain_password: str, hashed_password: str) -> bool:
return await asyncio.to_thread(
verify_password_sync, plain_password, hashed_password
)


def get_password_hash_sync(password: str) -> str:
return str(pwd_context.hash(password))


async def get_password_hash(password: str) -> str:
return await asyncio.to_thread(get_password_hash_sync, password)


def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
if expires_delta:
Expand Down
6 changes: 6 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@
from app.core.lua_scripts import RATE_LIMIT_LUA_SCRIPT
from app.core.s3 import init_s3_bucket
from app.core.setup import setup_exception_handlers
from app.services.buyer_user.routes import router_v1 as buyer_user_router_v1
from app.services.external.routes import router_v1 as external_router_v1
from app.services.inventory.routes import router_v1 as inventory_router_v1
from app.services.media.routes import router_v1 as media_router_v1
from app.services.orders.routes import router_v1 as order_router_v1
from app.services.seller_user.routes import router_v1 as seller_user_router_v1
from app.services.user.routes import router_v1 as user_router_v1

setup_logging()
Expand Down Expand Up @@ -88,6 +91,9 @@ async def add_request_context(
app.include_router(order_router_v1, prefix='/api/v1', tags=['Orders'])
app.include_router(inventory_router_v1, prefix='/api/v1', tags=['Inventory'])
app.include_router(media_router_v1, prefix='/api/v1', tags=['Media'])
app.include_router(seller_user_router_v1, prefix='/api/v1', tags=['Seller Dashboard'])
app.include_router(buyer_user_router_v1, prefix='/api/v1', tags=['Buyer Dashboard'])
app.include_router(external_router_v1, prefix='/api/v1', tags=['Partner API'])


@app.get('/health')
Expand Down
Empty file.
39 changes: 39 additions & 0 deletions app/services/buyer_user/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_session
from app.core.security import RoleChecker
from app.services.buyer_user.schemas import (
BuyerOrderRead,
BuyerStats,
)
from app.services.buyer_user.service import (
get_my_orders,
get_my_stats,
)
from app.services.orders.models import OrderStatus
from app.services.user.models import User, UserRole

router_v1 = APIRouter(prefix='/buyer_user', tags=['Buyer Dashboard'])

BUYER_DEPENDENCY = Depends(
RoleChecker(allowed_roles=[UserRole.USER, UserRole.USER_B2B])
)


@router_v1.get('/stats', response_model=BuyerStats)
async def fetch_my_stats(
session: AsyncSession = Depends(get_session),
current_user: User = BUYER_DEPENDENCY,
) -> BuyerStats:
return await get_my_stats(session, current_user.id)


@router_v1.get('/orders', response_model=list[BuyerOrderRead])
async def fetch_my_orders(
status: OrderStatus | None = Query(None),
session: AsyncSession = Depends(get_session),
current_user: User = BUYER_DEPENDENCY,
) -> list[BuyerOrderRead]:
orders = await get_my_orders(session, current_user.id, status)
return [BuyerOrderRead.model_validate(o) for o in orders]
31 changes: 31 additions & 0 deletions app/services/buyer_user/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from datetime import datetime
from decimal import Decimal
from uuid import UUID

from pydantic import BaseModel, ConfigDict


class BuyerStats(BaseModel):
total_orders: int
pending_orders: int
paid_orders: int
shipped_orders: int


class BuyerOrderItemRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
product_id: UUID
product_name: str
quantity: int
price: Decimal


class BuyerOrderRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
status: str
total_amount: Decimal
shipping_address: str | None = None
created_at: datetime
items: list[BuyerOrderItemRead]
46 changes: 46 additions & 0 deletions app/services/buyer_user/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from collections.abc import Sequence
from uuid import UUID

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from app.services.buyer_user.schemas import BuyerStats
from app.services.orders.models import Order, OrderStatus


async def get_my_orders(
session: AsyncSession,
user_id: UUID,
status: OrderStatus | None = None,
) -> Sequence[Order]:
stmt = (
select(Order)
.where(Order.user_id == user_id)
.options(selectinload(Order.items))
.order_by(Order.created_at.desc())
)
if status:
stmt = stmt.where(Order.status == status)
result = await session.execute(stmt)
return result.scalars().all()


async def get_my_stats(
session: AsyncSession,
user_id: UUID,
) -> BuyerStats:
stmt = (
select(Order.status, func.count(Order.id))
.where(Order.user_id == user_id)
.group_by(Order.status)
)
result = await session.execute(stmt)
counts: dict[OrderStatus, int] = {row[0]: row[1] for row in result.all()}

return BuyerStats(
total_orders=sum(counts.values()),
pending_orders=counts.get(OrderStatus.PENDING, 0),
paid_orders=counts.get(OrderStatus.PAID, 0),
shipped_orders=counts.get(OrderStatus.SHIPPED, 0),
)
Empty file.
42 changes: 42 additions & 0 deletions app/services/external/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from uuid import UUID

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

from app.core.database import get_session
from app.services.external.schemas import (
ExternalOrderResponse,
ExternalProductRead,
)
from app.services.external.service import (
get_external_catalog,
get_external_order_status,
)
from app.services.user.models import User
from app.shared.deps import get_api_key_user

router_v1 = APIRouter(prefix='/external', tags=['Partner API'])


@router_v1.get('/catalog', response_model=list[ExternalProductRead])
async def fetch_catalog(
session: AsyncSession = Depends(get_session),
current_partner: User = Depends(get_api_key_user),
) -> list[ExternalProductRead]:
products = await get_external_catalog(session)
return [ExternalProductRead.model_validate(p) for p in products]


@router_v1.get('/orders/{order_id}/status', response_model=ExternalOrderResponse)
async def fetch_order_status(
order_id: UUID,
session: AsyncSession = Depends(get_session),
current_partner: User = Depends(get_api_key_user),
) -> ExternalOrderResponse:
order = await get_external_order_status(session, current_partner.id, order_id)
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Order not found or access denied',
)
return ExternalOrderResponse.model_validate(order)
20 changes: 20 additions & 0 deletions app/services/external/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from datetime import datetime
from decimal import Decimal
from uuid import UUID

from pydantic import BaseModel, ConfigDict


class ExternalProductRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
price: Decimal
qty_available: int


class ExternalOrderResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
status: str
updated_at: datetime
30 changes: 30 additions & 0 deletions app/services/external/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from collections.abc import Sequence
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.services.inventory.models import Product, ProductStatus
from app.services.orders.models import Order


async def get_external_catalog(
session: AsyncSession,
) -> Sequence[Product]:
stmt = (
select(Product)
.where(Product.status == ProductStatus.ACTIVE)
.order_by(Product.name)
)
result = await session.execute(stmt)
return result.scalars().all()


async def get_external_order_status(
session: AsyncSession,
user_id: UUID,
order_id: UUID,
) -> Order | None:
stmt = select(Order).where(Order.id == order_id, Order.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
1 change: 1 addition & 0 deletions app/services/inventory/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class Product(Base):
moderator_id: Mapped[UUID | None] = mapped_column(
ForeignKey('users.id'), nullable=True, index=True
)
moderation_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
if TYPE_CHECKING:
from app.services.media.models import ProductImage
images: Mapped[list['ProductImage']] = relationship(
Expand Down
Loading