From c324dac7ece779e918cc297af4a4e239e9eeee67 Mon Sep 17 00:00:00 2001 From: Code With Me Date: Fri, 3 Apr 2026 20:04:34 +0300 Subject: [PATCH] feat: implement buyer and seller service layers, add product moderation comments, and refactor security authentication schemes --- app/core/admin/admin.py | 28 +++- app/core/auth_schemes.py | 4 + app/core/hashing.py | 23 ++++ app/core/security.py | 25 +--- app/main.py | 6 + app/services/buyer_user/__init__.py | 0 app/services/buyer_user/routes.py | 39 ++++++ app/services/buyer_user/schemas.py | 31 +++++ app/services/buyer_user/service.py | 46 +++++++ app/services/external/__init__.py | 0 app/services/external/routes.py | 42 ++++++ app/services/external/schemas.py | 20 +++ app/services/external/service.py | 30 +++++ app/services/inventory/models.py | 1 + app/services/media/routes.py | 24 +++- app/services/media/service.py | 18 ++- app/services/orders/models.py | 4 + app/services/seller_user/__init__.py | 0 app/services/seller_user/routes.py | 54 ++++++++ app/services/seller_user/schemas.py | 48 +++++++ app/services/seller_user/service.py | 124 ++++++++++++++++++ app/services/user/service.py | 2 +- app/shared/deps.py | 21 ++- ...bb315592_add_product_moderation_comment.py | 32 +++++ 24 files changed, 589 insertions(+), 33 deletions(-) create mode 100644 app/core/auth_schemes.py create mode 100644 app/core/hashing.py create mode 100644 app/services/buyer_user/__init__.py create mode 100644 app/services/buyer_user/routes.py create mode 100644 app/services/buyer_user/schemas.py create mode 100644 app/services/buyer_user/service.py create mode 100644 app/services/external/__init__.py create mode 100644 app/services/external/routes.py create mode 100644 app/services/external/schemas.py create mode 100644 app/services/external/service.py create mode 100644 app/services/seller_user/__init__.py create mode 100644 app/services/seller_user/routes.py create mode 100644 app/services/seller_user/schemas.py create mode 100644 app/services/seller_user/service.py create mode 100644 migrations/versions/93c6bb315592_add_product_moderation_comment.py 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 ###