feat(modules): create cart, catalog, and checkout e-commerce modules

Phase 3 of storefront restructure plan - create dedicated modules for
e-commerce functionality:

- cart: Shopping cart management with storefront API routes
  - CartItem model with cents-based pricing
  - CartService for cart operations
  - Storefront routes for cart CRUD operations

- catalog: Product catalog browsing for customers
  - CatalogService for public product queries
  - Storefront routes for product listing/search/details

- checkout: Order creation from cart (placeholder)
  - CheckoutService stub for future cart-to-order conversion
  - Schemas for checkout flow

These modules separate e-commerce concerns from core platform
concerns (customer auth), enabling non-commerce platforms.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 22:53:35 +01:00
parent 3e86d4b58b
commit 0845555413
32 changed files with 1748 additions and 3 deletions

View File

@@ -0,0 +1,9 @@
# app/modules/catalog/__init__.py
"""
Catalog Module
Provides product catalog functionality for storefronts, including:
- Product browsing and listing
- Product search
- Product details
"""

View File

@@ -0,0 +1,17 @@
# app/modules/catalog/definition.py
"""Catalog module definition."""
from app.modules.base import ModuleDefinition
module = ModuleDefinition(
code="catalog",
name="Product Catalog",
description="Product catalog browsing and search for storefronts",
version="1.0.0",
is_self_contained=True,
dependencies=["inventory"],
provides_models=False, # Uses Product model from products module
provides_schemas=True,
provides_services=True,
provides_api_routes=True,
)

View File

@@ -0,0 +1,7 @@
# app/modules/catalog/models/__init__.py
"""
Catalog module models.
Note: The catalog module uses the Product model from the products module.
This file exists for consistency with the module structure.
"""

View File

@@ -0,0 +1,2 @@
# app/modules/catalog/routes/__init__.py
"""Catalog module routes."""

View File

@@ -0,0 +1,9 @@
# app/modules/catalog/routes/api/__init__.py
"""Catalog module API routes."""
from app.modules.catalog.routes.api.storefront import router as storefront_router
# Tag for OpenAPI documentation
STOREFRONT_TAG = "Catalog (Storefront)"
__all__ = ["storefront_router", "STOREFRONT_TAG"]

View File

@@ -0,0 +1,170 @@
# app/modules/catalog/routes/api/storefront.py
"""
Catalog Module - Storefront API Routes
Public endpoints for browsing product catalog in storefront.
Uses vendor from middleware context (VendorContextMiddleware).
No authentication required.
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
"""
import logging
from fastapi import APIRouter, Depends, Path, Query, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.catalog.services import catalog_service
from app.modules.catalog.schemas import (
ProductDetailResponse,
ProductListResponse,
ProductResponse,
)
from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/products", response_model=ProductListResponse) # public
def get_product_catalog(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: str | None = Query(None, description="Search products by name"),
is_featured: bool | None = Query(None, description="Filter by featured products"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
):
"""
Get product catalog for current vendor.
Vendor is automatically determined from request context (URL/subdomain/domain).
Only returns active products visible to customers.
No authentication required.
Query Parameters:
- skip: Number of products to skip (pagination)
- limit: Maximum number of products to return
- search: Search query for product name/description
- is_featured: Filter by featured products only
"""
logger.debug(
f"[CATALOG_STOREFRONT] get_product_catalog for vendor: {vendor.subdomain}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"skip": skip,
"limit": limit,
"search": search,
"is_featured": is_featured,
},
)
# Get only active products for public view
products, total = catalog_service.get_catalog_products(
db=db,
vendor_id=vendor.id,
skip=skip,
limit=limit,
is_featured=is_featured,
)
return ProductListResponse(
products=[ProductResponse.model_validate(p) for p in products],
total=total,
skip=skip,
limit=limit,
)
@router.get("/products/{product_id}", response_model=ProductDetailResponse) # public
def get_product_details(
product_id: int = Path(..., description="Product ID", gt=0),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
):
"""
Get detailed product information for customers.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required.
Path Parameters:
- product_id: ID of the product to retrieve
"""
logger.debug(
f"[CATALOG_STOREFRONT] get_product_details for product {product_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"product_id": product_id,
},
)
product = catalog_service.get_product(
db=db, vendor_id=vendor.id, product_id=product_id
)
# Check if product is active
if not product.is_active:
from app.exceptions import ProductNotActiveException
raise ProductNotActiveException(str(product_id))
return ProductDetailResponse.model_validate(product)
@router.get("/products/search", response_model=ProductListResponse) # public
def search_products(
request: Request,
q: str = Query(..., min_length=1, description="Search query"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
):
"""
Search products in current vendor's catalog.
Searches in product names, descriptions, SKUs, brands, and GTINs.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required.
Query Parameters:
- q: Search query string (minimum 1 character)
- skip: Number of results to skip (pagination)
- limit: Maximum number of results to return
"""
# Get preferred language from request (via middleware or default)
language = getattr(request.state, "language", "en")
logger.debug(
f"[CATALOG_STOREFRONT] search_products: '{q}'",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"query": q,
"skip": skip,
"limit": limit,
"language": language,
},
)
# Search products using the service
products, total = catalog_service.search_products(
db=db,
vendor_id=vendor.id,
query=q,
skip=skip,
limit=limit,
language=language,
)
return ProductListResponse(
products=[ProductResponse.model_validate(p) for p in products],
total=total,
skip=skip,
limit=limit,
)

View File

@@ -0,0 +1,14 @@
# app/modules/catalog/schemas/__init__.py
"""Catalog module schemas."""
from app.modules.catalog.schemas.catalog import (
ProductDetailResponse,
ProductListResponse,
ProductResponse,
)
__all__ = [
"ProductResponse",
"ProductDetailResponse",
"ProductListResponse",
]

View File

@@ -0,0 +1,56 @@
# app/modules/catalog/schemas/catalog.py
"""
Pydantic schemas for catalog browsing operations.
These schemas are for the public storefront catalog API.
For vendor product management, see the products module.
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from models.schema.inventory import InventoryLocationResponse
from models.schema.marketplace_product import MarketplaceProductResponse
class ProductResponse(BaseModel):
"""Response model for a product in the catalog."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
marketplace_product: MarketplaceProductResponse
vendor_sku: str | None
price: float | None
sale_price: float | None
currency: str | None
availability: str | None
condition: str | None
is_featured: bool
is_active: bool
display_order: int
min_quantity: int
max_quantity: int | None
created_at: datetime
updated_at: datetime
# Include inventory summary
total_inventory: int | None = None
available_inventory: int | None = None
class ProductDetailResponse(ProductResponse):
"""Product with full inventory details."""
inventory_locations: list[InventoryLocationResponse] = []
class ProductListResponse(BaseModel):
"""Paginated list of products."""
products: list[ProductResponse]
total: int
skip: int
limit: int

View File

@@ -0,0 +1,6 @@
# app/modules/catalog/services/__init__.py
"""Catalog module services."""
from app.modules.catalog.services.catalog_service import catalog_service
__all__ = ["catalog_service"]

View File

@@ -0,0 +1,183 @@
# app/modules/catalog/services/catalog_service.py
"""
Catalog service for storefront product browsing.
This module provides:
- Public product catalog retrieval
- Product search functionality
- Product detail retrieval
Note: This is distinct from the product_service which handles
vendor product management. The catalog service is for public
storefront operations only.
"""
import logging
from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload
from app.exceptions import ProductNotFoundException, ValidationException
from models.database.product import Product
from models.database.product_translation import ProductTranslation
logger = logging.getLogger(__name__)
class CatalogService:
"""Service for public catalog browsing operations."""
def get_product(self, db: Session, vendor_id: int, product_id: int) -> Product:
"""
Get a product from vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
Returns:
Product object
Raises:
ProductNotFoundException: If product not found
"""
product = (
db.query(Product)
.filter(Product.id == product_id, Product.vendor_id == vendor_id)
.first()
)
if not product:
raise ProductNotFoundException(f"Product {product_id} not found")
return product
def get_catalog_products(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 100,
is_featured: bool | None = None,
) -> tuple[list[Product], int]:
"""
Get products in vendor catalog for public display.
Only returns active products visible to customers.
Args:
db: Database session
vendor_id: Vendor ID
skip: Pagination offset
limit: Pagination limit
is_featured: Filter by featured status
Returns:
Tuple of (products, total_count)
"""
try:
# Always filter for active products only
query = db.query(Product).filter(
Product.vendor_id == vendor_id,
Product.is_active == True,
)
if is_featured is not None:
query = query.filter(Product.is_featured == is_featured)
total = query.count()
products = query.offset(skip).limit(limit).all()
return products, total
except Exception as e:
logger.error(f"Error getting catalog products: {str(e)}")
raise ValidationException("Failed to retrieve products")
def search_products(
self,
db: Session,
vendor_id: int,
query: str,
skip: int = 0,
limit: int = 50,
language: str = "en",
) -> tuple[list[Product], int]:
"""
Search products in vendor catalog.
Searches across:
- Product title and description (from translations)
- Product SKU, brand, and GTIN
Args:
db: Database session
vendor_id: Vendor ID
query: Search query string
skip: Pagination offset
limit: Pagination limit
language: Language for translation search (default: 'en')
Returns:
Tuple of (products, total_count)
"""
try:
# Prepare search pattern for LIKE queries
search_pattern = f"%{query}%"
# Use subquery to get distinct IDs (PostgreSQL can't compare JSON for DISTINCT)
id_subquery = (
db.query(Product.id)
.outerjoin(
ProductTranslation,
(Product.id == ProductTranslation.product_id)
& (ProductTranslation.language == language),
)
.filter(
Product.vendor_id == vendor_id,
Product.is_active == True,
)
.filter(
or_(
# Search in translations
ProductTranslation.title.ilike(search_pattern),
ProductTranslation.description.ilike(search_pattern),
ProductTranslation.short_description.ilike(search_pattern),
# Search in product fields
Product.vendor_sku.ilike(search_pattern),
Product.brand.ilike(search_pattern),
Product.gtin.ilike(search_pattern),
)
)
.distinct()
.subquery()
)
base_query = db.query(Product).filter(
Product.id.in_(db.query(id_subquery.c.id))
)
# Get total count
total = base_query.count()
# Get paginated results with eager loading for performance
products = (
base_query.options(joinedload(Product.translations))
.offset(skip)
.limit(limit)
.all()
)
logger.debug(
f"Search '{query}' for vendor {vendor_id}: {total} results"
)
return products, total
except Exception as e:
logger.error(f"Error searching products: {str(e)}")
raise ValidationException("Failed to search products")
# Create service instance
catalog_service = CatalogService()