diff --git a/app/core/admin/admin.py b/app/core/admin/admin.py
index 49cdd91..d385b7f 100644
--- a/app/core/admin/admin.py
+++ b/app/core/admin/admin.py
@@ -75,6 +75,20 @@ def order_link_formatter(model: Any, name: Any) -> Any:
return 'N/A'
return Markup(f'{order_id}')
+ @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'{doc_type}'
+ )
+ return Markup(', '.join(links))
+
class VerificationRequestAdmin(ModelView, model=VerificationRequest):
column_list = [
@@ -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'
@@ -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)]
@@ -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):
diff --git a/app/core/auth_schemes.py b/app/core/auth_schemes.py
new file mode 100644
index 0000000..56fce34
--- /dev/null
+++ b/app/core/auth_schemes.py
@@ -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)
diff --git a/app/core/hashing.py b/app/core/hashing.py
new file mode 100644
index 0000000..025b283
--- /dev/null
+++ b/app/core/hashing.py
@@ -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)
diff --git a/app/core/security.py b/app/core/security.py
index c2bce2f..47eeee7 100644
--- a/app/core/security.py
+++ b/app/core/security.py
@@ -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
@@ -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:
diff --git a/app/main.py b/app/main.py
index 5787344..931c1a1 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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()
@@ -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')
diff --git a/app/services/buyer_user/__init__.py b/app/services/buyer_user/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/services/buyer_user/routes.py b/app/services/buyer_user/routes.py
new file mode 100644
index 0000000..4d48912
--- /dev/null
+++ b/app/services/buyer_user/routes.py
@@ -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]
diff --git a/app/services/buyer_user/schemas.py b/app/services/buyer_user/schemas.py
new file mode 100644
index 0000000..39c7170
--- /dev/null
+++ b/app/services/buyer_user/schemas.py
@@ -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]
diff --git a/app/services/buyer_user/service.py b/app/services/buyer_user/service.py
new file mode 100644
index 0000000..e7b5c07
--- /dev/null
+++ b/app/services/buyer_user/service.py
@@ -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),
+ )
diff --git a/app/services/external/__init__.py b/app/services/external/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/services/external/routes.py b/app/services/external/routes.py
new file mode 100644
index 0000000..20f1be2
--- /dev/null
+++ b/app/services/external/routes.py
@@ -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)
diff --git a/app/services/external/schemas.py b/app/services/external/schemas.py
new file mode 100644
index 0000000..d63168b
--- /dev/null
+++ b/app/services/external/schemas.py
@@ -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
diff --git a/app/services/external/service.py b/app/services/external/service.py
new file mode 100644
index 0000000..7f59f12
--- /dev/null
+++ b/app/services/external/service.py
@@ -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()
diff --git a/app/services/inventory/models.py b/app/services/inventory/models.py
index d355a7a..d718fed 100644
--- a/app/services/inventory/models.py
+++ b/app/services/inventory/models.py
@@ -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(
diff --git a/app/services/media/routes.py b/app/services/media/routes.py
index 23d3045..8f9cb31 100644
--- a/app/services/media/routes.py
+++ b/app/services/media/routes.py
@@ -2,6 +2,7 @@
from uuid import UUID
from fastapi import APIRouter, Depends
+from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
@@ -11,8 +12,12 @@
ImageUploadResponse,
MinioWebhookEvent,
)
-from app.services.media.service import generate_upload_url, handle_minio_webhook
-from app.services.user.models import User
+from app.services.media.service import (
+ generate_presigned_get_url,
+ generate_upload_url,
+ handle_minio_webhook,
+)
+from app.services.user.models import User, UserRole
from app.shared.deps import get_current_user
router_v1 = APIRouter(prefix='/media', tags=['Media'])
@@ -36,3 +41,18 @@ async def minio_webhook(
) -> dict[str, str]:
await handle_minio_webhook(session, event)
return {'status': 'ok'}
+
+
+@router_v1.get('/view', response_class=RedirectResponse)
+async def view_private_file(
+ key: str,
+ s3_client: Any = Depends(get_s3_client),
+ current_user: User = Depends(get_current_user),
+) -> RedirectResponse:
+ if current_user.role not in (UserRole.ADMIN, UserRole.MODERATOR):
+ from fastapi import HTTPException
+
+ raise HTTPException(status_code=403, detail='Not authorized to view this file')
+
+ url = await generate_presigned_get_url(s3_client, key)
+ return RedirectResponse(url=url, status_code=307)
diff --git a/app/services/media/service.py b/app/services/media/service.py
index a9f43f2..9ce847d 100644
--- a/app/services/media/service.py
+++ b/app/services/media/service.py
@@ -1,4 +1,4 @@
-from typing import Any
+from typing import Any, cast
from urllib.parse import unquote
from uuid import UUID, uuid4
@@ -18,6 +18,22 @@
logger = structlog.get_logger(__name__)
+async def generate_presigned_get_url(
+ s3_client: Any,
+ key: str,
+ expires_in: int = 3600,
+) -> str:
+ """Generates a presigned GET URL for reading private files."""
+ return cast(
+ str,
+ await s3_client.generate_presigned_url(
+ 'get_object',
+ Params={'Bucket': settings.minio_bucket_name, 'Key': key},
+ ExpiresIn=expires_in,
+ ),
+ )
+
+
async def generate_upload_url(
session: AsyncSession,
s3_client: Any,
diff --git a/app/services/orders/models.py b/app/services/orders/models.py
index c052853..4c06f4b 100644
--- a/app/services/orders/models.py
+++ b/app/services/orders/models.py
@@ -1,6 +1,7 @@
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
+from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import CheckConstraint, Enum, ForeignKey, Numeric, Text
@@ -62,6 +63,9 @@ class OrderItem(Base):
product_id: Mapped[UUID] = mapped_column(
ForeignKey('products.id'), nullable=False, index=True
)
+ if TYPE_CHECKING:
+ from app.services.inventory.models import Product
+ product: Mapped['Product'] = relationship('Product')
order: Mapped['Order'] = relationship('Order', back_populates='items')
product_name: Mapped[str] = mapped_column(String(), nullable=False)
diff --git a/app/services/seller_user/__init__.py b/app/services/seller_user/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/services/seller_user/routes.py b/app/services/seller_user/routes.py
new file mode 100644
index 0000000..06f52a5
--- /dev/null
+++ b/app/services/seller_user/routes.py
@@ -0,0 +1,54 @@
+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.inventory.models import ProductStatus
+from app.services.orders.models import OrderStatus
+from app.services.seller_user.schemas import (
+ SellerOrderRead,
+ SellerProductRead,
+ SellerStats,
+)
+from app.services.seller_user.service import (
+ get_my_orders,
+ get_my_products,
+ get_my_stats,
+)
+from app.services.user.models import User, UserRole
+
+router_v1 = APIRouter(prefix='/seller_user', tags=['Seller Dashboard'])
+
+SELLER_DEPENDENCY = Depends(
+ RoleChecker(
+ allowed_roles=[UserRole.SELLER, UserRole.SELLER_B2B],
+ required_verified=True,
+ )
+)
+
+
+@router_v1.get('/stats', response_model=SellerStats)
+async def fetch_my_stats(
+ session: AsyncSession = Depends(get_session),
+ current_user: User = SELLER_DEPENDENCY,
+) -> SellerStats:
+ return await get_my_stats(session, current_user.id)
+
+
+@router_v1.get('/products', response_model=list[SellerProductRead])
+async def fetch_my_products(
+ status: ProductStatus | None = Query(None),
+ session: AsyncSession = Depends(get_session),
+ current_user: User = SELLER_DEPENDENCY,
+) -> list[SellerProductRead]:
+ products = await get_my_products(session, current_user.id, status)
+ return [SellerProductRead.model_validate(p) for p in products]
+
+
+@router_v1.get('/orders', response_model=list[SellerOrderRead])
+async def fetch_my_orders(
+ status: OrderStatus | None = Query(None),
+ session: AsyncSession = Depends(get_session),
+ current_user: User = SELLER_DEPENDENCY,
+) -> list[SellerOrderRead]:
+ return await get_my_orders(session, current_user.id, status)
diff --git a/app/services/seller_user/schemas.py b/app/services/seller_user/schemas.py
new file mode 100644
index 0000000..fddea72
--- /dev/null
+++ b/app/services/seller_user/schemas.py
@@ -0,0 +1,48 @@
+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
+from app.services.orders.models import OrderStatus
+
+
+class SellerProductRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ name: str
+ description: str | None = None
+ price: Decimal
+ qty_available: int
+ status: ProductStatus
+ moderation_comment: str | None = None
+ created_at: datetime
+ updated_at: datetime
+
+
+class SellerOrderItemRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ product_id: UUID
+ product_name: str
+ quantity: int
+ price: Decimal
+
+
+class SellerOrderRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ status: OrderStatus
+ created_at: datetime
+ shipping_address: str | None = None
+ seller_items: list[SellerOrderItemRead] = Field(default_factory=list)
+
+
+class SellerStats(BaseModel):
+ total_products: int = 0
+ active_products: int = 0
+ pending_moderation: int = 0
+ rejected_products: int = 0
+ pending_orders: int = 0
+ paid_orders: int = 0
diff --git a/app/services/seller_user/service.py b/app/services/seller_user/service.py
new file mode 100644
index 0000000..0e0cee3
--- /dev/null
+++ b/app/services/seller_user/service.py
@@ -0,0 +1,124 @@
+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.inventory.models import Product, ProductStatus
+from app.services.orders.models import Order, OrderItem, OrderStatus
+from app.services.seller_user.schemas import (
+ SellerOrderItemRead,
+ SellerOrderRead,
+ SellerStats,
+)
+
+
+async def get_my_products(
+ session: AsyncSession,
+ user_id: UUID,
+ status: ProductStatus | None = None,
+) -> Sequence[Product]:
+ stmt = select(Product).where(Product.owner_id == user_id)
+ if status:
+ stmt = stmt.where(Product.status == status)
+ stmt = stmt.order_by(Product.created_at.desc())
+ result = await session.execute(stmt)
+ return result.scalars().all()
+
+
+async def get_my_orders(
+ session: AsyncSession,
+ user_id: UUID,
+ status: OrderStatus | None = None,
+) -> list[SellerOrderRead]:
+ order_id_stmt = (
+ select(Order.id)
+ .join(OrderItem, OrderItem.order_id == Order.id)
+ .join(Product, Product.id == OrderItem.product_id)
+ .where(Product.owner_id == user_id)
+ )
+ if status:
+ order_id_stmt = order_id_stmt.where(Order.status == status)
+ order_ids_result = await session.execute(order_id_stmt)
+ order_ids = order_ids_result.scalars().all()
+ if not order_ids:
+ return []
+ stmt = (
+ select(Order)
+ .where(Order.id.in_(order_ids))
+ .options(selectinload(Order.items))
+ .order_by(Order.created_at.desc())
+ )
+ result = await session.execute(stmt)
+ orders = result.scalars().all()
+ seller_orders = []
+ for order in orders:
+ my_items = [
+ SellerOrderItemRead(
+ id=item.id,
+ product_id=item.product_id,
+ product_name=item.product_name,
+ quantity=item.quantity,
+ price=item.price,
+ )
+ for item in order.items
+ if item.product.owner_id == user_id
+ ]
+ display_address = None
+ if order.status not in (
+ OrderStatus.PENDING,
+ OrderStatus.FAILED,
+ OrderStatus.CANCELLED,
+ ):
+ display_address = order.shipping_address
+ seller_orders.append(
+ SellerOrderRead(
+ id=order.id,
+ status=order.status,
+ created_at=order.created_at,
+ shipping_address=display_address,
+ seller_items=my_items,
+ )
+ )
+ return seller_orders
+
+
+async def get_my_stats(
+ session: AsyncSession,
+ user_id: UUID,
+) -> SellerStats:
+ prod_stmt = (
+ select(
+ Product.status,
+ func.count(Product.id),
+ )
+ .where(Product.owner_id == user_id)
+ .group_by(Product.status)
+ )
+ prod_result = await session.execute(prod_stmt)
+ prod_counts: dict[ProductStatus, int] = {
+ row[0]: row[1] for row in prod_result.all()
+ }
+ order_stmt = (
+ select(
+ Order.status,
+ func.count(func.distinct(Order.id)),
+ )
+ .join(OrderItem, OrderItem.order_id == Order.id)
+ .join(Product, Product.id == OrderItem.product_id)
+ .where(Product.owner_id == user_id)
+ .group_by(Order.status)
+ )
+ order_result = await session.execute(order_stmt)
+ order_counts: dict[OrderStatus, int] = {
+ row[0]: row[1] for row in order_result.all()
+ }
+ return SellerStats(
+ total_products=sum(prod_counts.values()),
+ active_products=prod_counts.get(ProductStatus.ACTIVE, 0),
+ pending_moderation=prod_counts.get(ProductStatus.PENDING_MODERATION, 0),
+ rejected_products=prod_counts.get(ProductStatus.REJECTED, 0),
+ pending_orders=order_counts.get(OrderStatus.PENDING, 0),
+ paid_orders=order_counts.get(OrderStatus.PAID, 0),
+ )
diff --git a/app/services/user/service.py b/app/services/user/service.py
index c0a9873..b24598b 100644
--- a/app/services/user/service.py
+++ b/app/services/user/service.py
@@ -14,7 +14,7 @@
UserAlreadyExists,
VerificationRequestAlreadyExists,
)
-from app.core.security import get_password_hash, verify_password
+from app.core.hashing import get_password_hash, verify_password
from app.services.user.models import (
APIKeyB2BPartner,
RefreshToken,
diff --git a/app/shared/deps.py b/app/shared/deps.py
index 9d70e49..4aafa19 100644
--- a/app/shared/deps.py
+++ b/app/shared/deps.py
@@ -1,16 +1,15 @@
from typing import Annotated
from fastapi import Depends
-from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy import select
+from app.core.auth_schemes import header_scheme, oauth2_scheme
from app.core.config import settings
from app.core.database import SessionDep
-from app.core.exceptions import CredentialsError
+from app.core.exceptions import CredentialsError, PermissionDeniedError
from app.services.user.models import User
-
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/api/v1/auth/token')
+from app.services.user.service import UserService
async def get_current_user(
@@ -31,3 +30,17 @@ async def get_current_user(
return user
except JWTError:
raise CredentialsError()
+
+
+async def get_api_key_user(
+ api_key: Annotated[str | None, Depends(header_scheme)],
+ session: SessionDep,
+) -> User:
+ if not api_key:
+ raise PermissionDeniedError('Missing API Key')
+ api_key_record = await UserService.authenticate_api_key_b2b_partner(
+ session, api_key
+ )
+ if not api_key_record:
+ raise PermissionDeniedError('Invalid API Key')
+ return api_key_record.user
diff --git a/migrations/versions/93c6bb315592_add_product_moderation_comment.py b/migrations/versions/93c6bb315592_add_product_moderation_comment.py
new file mode 100644
index 0000000..dd0c89b
--- /dev/null
+++ b/migrations/versions/93c6bb315592_add_product_moderation_comment.py
@@ -0,0 +1,32 @@
+"""add_product_moderation_comment
+
+Revision ID: 93c6bb315592
+Revises: 96dda86ac1b0
+Create Date: 2026-04-03 16:37:11.465877
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = '93c6bb315592'
+down_revision: str | Sequence[str] | None = '96dda86ac1b0'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('products', sa.Column('moderation_comment', sa.Text(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('products', 'moderation_comment')
+ # ### end Alembic commands ###