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,11 @@
# app/modules/cart/__init__.py
"""
Cart Module
Provides shopping cart functionality for storefronts:
- Session-based cart management
- Cart item operations (add, update, remove)
- Cart total calculations
All monetary calculations use integer cents internally for precision.
"""

View File

@@ -0,0 +1,25 @@
# app/modules/cart/definition.py
"""
Cart module definition.
This module provides shopping cart functionality for customer storefronts.
It is session-based and does not require customer authentication.
"""
from app.modules.core.module_registry import ModuleDefinition
module = ModuleDefinition(
code="cart",
name="Shopping Cart",
description="Session-based shopping cart for storefronts",
version="1.0.0",
is_self_contained=True,
dependencies=["inventory"], # Checks inventory availability
provides_models=True,
provides_schemas=True,
provides_services=True,
provides_api_routes=True,
provides_page_routes=False,
provides_admin_ui=False,
provides_vendor_ui=False,
)

View File

@@ -0,0 +1,6 @@
# app/modules/cart/models/__init__.py
"""Cart module database models."""
from app.modules.cart.models.cart import CartItem
__all__ = ["CartItem"]

View File

@@ -0,0 +1,78 @@
# app/modules/cart/models/cart.py
"""Cart item database model.
Money values are stored as integer cents (e.g., €105.91 = 10591).
See docs/architecture/money-handling.md for details.
"""
from sqlalchemy import (
Column,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin
class CartItem(Base, TimestampMixin):
"""
Shopping cart items.
Stores cart items per session, vendor, and product.
Sessions are identified by a session_id string (from browser cookies).
Price is stored as integer cents for precision.
"""
__tablename__ = "cart_items"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
session_id = Column(String(255), nullable=False, index=True)
# Cart details
quantity = Column(Integer, nullable=False, default=1)
price_at_add_cents = Column(Integer, nullable=False) # Price in cents when added
# Relationships
vendor = relationship("Vendor")
product = relationship("Product")
# Constraints
__table_args__ = (
UniqueConstraint("vendor_id", "session_id", "product_id", name="uq_cart_item"),
Index("idx_cart_session", "vendor_id", "session_id"),
Index("idx_cart_created", "created_at"), # For cleanup of old carts
)
def __repr__(self):
return f"<CartItem(id={self.id}, session='{self.session_id}', product_id={self.product_id}, qty={self.quantity})>"
# === PRICE PROPERTIES (Euro convenience accessors) ===
@property
def price_at_add(self) -> float:
"""Get price at add in euros."""
return cents_to_euros(self.price_at_add_cents)
@price_at_add.setter
def price_at_add(self, value: float):
"""Set price at add from euros."""
self.price_at_add_cents = euros_to_cents(value)
@property
def line_total_cents(self) -> int:
"""Calculate line total in cents."""
return self.price_at_add_cents * self.quantity
@property
def line_total(self) -> float:
"""Calculate line total in euros."""
return cents_to_euros(self.line_total_cents)

View File

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

View File

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

View File

@@ -0,0 +1,238 @@
# app/modules/cart/routes/api/storefront.py
"""
Cart Module - Storefront API Routes
Public endpoints for managing shopping cart in storefront.
Uses vendor from middleware context (VendorContextMiddleware).
No authentication required - uses session ID for cart tracking.
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
"""
import logging
from fastapi import APIRouter, Body, Depends, Path
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.cart.services import cart_service
from app.modules.cart.schemas import (
AddToCartRequest,
CartOperationResponse,
CartResponse,
ClearCartResponse,
UpdateCartItemRequest,
)
from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# CART ENDPOINTS
# ============================================================================
@router.get("/cart/{session_id}", response_model=CartResponse) # public
def get_cart(
session_id: str = Path(..., description="Shopping session ID"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartResponse:
"""
Get shopping cart contents for current vendor.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID for cart tracking.
Path Parameters:
- session_id: Unique session identifier for the cart
"""
logger.info(
f"[CART_STOREFRONT] get_cart for session {session_id}, vendor {vendor.id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"session_id": session_id,
},
)
cart = cart_service.get_cart(db=db, vendor_id=vendor.id, session_id=session_id)
logger.info(
f"[CART_STOREFRONT] get_cart result: {len(cart.get('items', []))} items in cart",
extra={
"session_id": session_id,
"vendor_id": vendor.id,
"item_count": len(cart.get("items", [])),
"total": cart.get("total", 0),
},
)
return CartResponse.from_service_dict(cart)
@router.post("/cart/{session_id}/items", response_model=CartOperationResponse) # public
def add_to_cart(
session_id: str = Path(..., description="Shopping session ID"),
cart_data: AddToCartRequest = Body(...),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartOperationResponse:
"""
Add product to cart for current vendor.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
- session_id: Unique session identifier for the cart
Request Body:
- product_id: ID of product to add
- quantity: Quantity to add (default: 1)
"""
logger.info(
f"[CART_STOREFRONT] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"session_id": session_id,
"product_id": cart_data.product_id,
"quantity": cart_data.quantity,
},
)
result = cart_service.add_to_cart(
db=db,
vendor_id=vendor.id,
session_id=session_id,
product_id=cart_data.product_id,
quantity=cart_data.quantity,
)
db.commit()
logger.info(
f"[CART_STOREFRONT] add_to_cart result: {result}",
extra={
"session_id": session_id,
"result": result,
},
)
return CartOperationResponse(**result)
@router.put(
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
) # public
def update_cart_item(
session_id: str = Path(..., description="Shopping session ID"),
product_id: int = Path(..., description="Product ID", gt=0),
cart_data: UpdateCartItemRequest = Body(...),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartOperationResponse:
"""
Update cart item quantity for current vendor.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
- session_id: Unique session identifier for the cart
- product_id: ID of product to update
Request Body:
- quantity: New quantity (must be >= 1)
"""
logger.debug(
f"[CART_STOREFRONT] update_cart_item: product {product_id}, qty {cart_data.quantity}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"session_id": session_id,
"product_id": product_id,
"quantity": cart_data.quantity,
},
)
result = cart_service.update_cart_item(
db=db,
vendor_id=vendor.id,
session_id=session_id,
product_id=product_id,
quantity=cart_data.quantity,
)
db.commit()
return CartOperationResponse(**result)
@router.delete(
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
) # public
def remove_from_cart(
session_id: str = Path(..., description="Shopping session ID"),
product_id: int = Path(..., description="Product ID", gt=0),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CartOperationResponse:
"""
Remove item from cart for current vendor.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
- session_id: Unique session identifier for the cart
- product_id: ID of product to remove
"""
logger.debug(
f"[CART_STOREFRONT] remove_from_cart: product {product_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"session_id": session_id,
"product_id": product_id,
},
)
result = cart_service.remove_from_cart(
db=db, vendor_id=vendor.id, session_id=session_id, product_id=product_id
)
db.commit()
return CartOperationResponse(**result)
@router.delete("/cart/{session_id}", response_model=ClearCartResponse) # public
def clear_cart(
session_id: str = Path(..., description="Shopping session ID"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> ClearCartResponse:
"""
Clear all items from cart for current vendor.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
- session_id: Unique session identifier for the cart
"""
logger.debug(
f"[CART_STOREFRONT] clear_cart for session {session_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"session_id": session_id,
},
)
result = cart_service.clear_cart(db=db, vendor_id=vendor.id, session_id=session_id)
db.commit()
return ClearCartResponse(**result)

View File

@@ -0,0 +1,20 @@
# app/modules/cart/schemas/__init__.py
"""Cart module Pydantic schemas."""
from app.modules.cart.schemas.cart import (
AddToCartRequest,
UpdateCartItemRequest,
CartItemResponse,
CartResponse,
CartOperationResponse,
ClearCartResponse,
)
__all__ = [
"AddToCartRequest",
"UpdateCartItemRequest",
"CartItemResponse",
"CartResponse",
"CartOperationResponse",
"ClearCartResponse",
]

View File

@@ -0,0 +1,91 @@
# app/modules/cart/schemas/cart.py
"""
Pydantic schemas for shopping cart operations.
"""
from pydantic import BaseModel, ConfigDict, Field
# ============================================================================
# Request Schemas
# ============================================================================
class AddToCartRequest(BaseModel):
"""Request model for adding items to cart."""
product_id: int = Field(..., description="Product ID to add", gt=0)
quantity: int = Field(1, ge=1, description="Quantity to add")
class UpdateCartItemRequest(BaseModel):
"""Request model for updating cart item quantity."""
quantity: int = Field(..., ge=1, description="New quantity (must be >= 1)")
# ============================================================================
# Response Schemas
# ============================================================================
class CartItemResponse(BaseModel):
"""Response model for a single cart item."""
model_config = ConfigDict(from_attributes=True)
product_id: int = Field(..., description="Product ID")
product_name: str = Field(..., description="Product name")
quantity: int = Field(..., description="Quantity in cart")
price: float = Field(..., description="Price per unit when added to cart")
line_total: float = Field(
..., description="Total price for this line (price * quantity)"
)
image_url: str | None = Field(None, description="Product image URL")
class CartResponse(BaseModel):
"""Response model for shopping cart."""
vendor_id: int = Field(..., description="Vendor ID")
session_id: str = Field(..., description="Shopping session ID")
items: list[CartItemResponse] = Field(
default_factory=list, description="Cart items"
)
subtotal: float = Field(..., description="Subtotal of all items")
total: float = Field(..., description="Total amount (currently same as subtotal)")
item_count: int = Field(..., description="Total number of items in cart")
@classmethod
def from_service_dict(cls, cart_dict: dict) -> "CartResponse":
"""
Create CartResponse from service layer dictionary.
This is a convenience method to convert the dictionary format
returned by cart_service into a proper Pydantic model.
"""
items = [CartItemResponse(**item) for item in cart_dict.get("items", [])]
return cls(
vendor_id=cart_dict["vendor_id"],
session_id=cart_dict["session_id"],
items=items,
subtotal=cart_dict["subtotal"],
total=cart_dict["total"],
item_count=len(items),
)
class CartOperationResponse(BaseModel):
"""Response model for cart operations (add, update, remove)."""
message: str = Field(..., description="Operation result message")
product_id: int = Field(..., description="Product ID affected")
quantity: int | None = Field(
None, description="New quantity (for add/update operations)"
)
class ClearCartResponse(BaseModel):
"""Response model for clearing cart."""
message: str = Field(..., description="Operation result message")
items_removed: int = Field(..., description="Number of items removed from cart")

View File

@@ -0,0 +1,6 @@
# app/modules/cart/services/__init__.py
"""Cart module services."""
from app.modules.cart.services.cart_service import cart_service, CartService
__all__ = ["cart_service", "CartService"]

View File

@@ -0,0 +1,453 @@
# app/modules/cart/services/cart_service.py
"""
Shopping cart service.
This module provides:
- Session-based cart management
- Cart item operations (add, update, remove)
- Cart total calculations
All monetary calculations use integer cents internally for precision.
See docs/architecture/money-handling.md for details.
"""
import logging
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.exceptions import (
CartItemNotFoundException,
InsufficientInventoryForCartException,
InvalidCartQuantityException,
ProductNotFoundException,
)
from app.utils.money import cents_to_euros
from app.modules.cart.models.cart import CartItem
from models.database.product import Product
logger = logging.getLogger(__name__)
class CartService:
"""Service for managing shopping carts."""
def get_cart(self, db: Session, vendor_id: int, session_id: str) -> dict:
"""
Get cart contents for a session.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Session ID
Returns:
Cart data with items and totals
"""
logger.info(
"[CART_SERVICE] get_cart called",
extra={
"vendor_id": vendor_id,
"session_id": session_id,
},
)
# Fetch cart items from database
cart_items = (
db.query(CartItem)
.filter(
and_(CartItem.vendor_id == vendor_id, CartItem.session_id == session_id)
)
.all()
)
logger.info(
f"[CART_SERVICE] Found {len(cart_items)} items in database",
extra={"item_count": len(cart_items)},
)
# Build response - calculate totals in cents, return euros
items = []
subtotal_cents = 0
for cart_item in cart_items:
product = cart_item.product
line_total_cents = cart_item.line_total_cents
items.append(
{
"product_id": product.id,
"product_name": product.marketplace_product.get_title("en")
if product.marketplace_product
else str(product.id),
"quantity": cart_item.quantity,
"price": cart_item.price_at_add, # Returns euros via property
"line_total": cents_to_euros(line_total_cents),
"image_url": (
product.marketplace_product.image_link
if product.marketplace_product
else None
),
}
)
subtotal_cents += line_total_cents
# Convert to euros for API response
subtotal = cents_to_euros(subtotal_cents)
cart_data = {
"vendor_id": vendor_id,
"session_id": session_id,
"items": items,
"subtotal": subtotal,
"total": subtotal, # Could add tax/shipping later
}
logger.info(
f"[CART_SERVICE] get_cart returning: {len(cart_data['items'])} items, total: {cart_data['total']}",
extra={"cart": cart_data},
)
return cart_data
def add_to_cart(
self,
db: Session,
vendor_id: int,
session_id: str,
product_id: int,
quantity: int = 1,
) -> dict:
"""
Add product to cart.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Session ID
product_id: Product ID
quantity: Quantity to add
Returns:
Updated cart
Raises:
ProductNotFoundException: If product not found
InsufficientInventoryException: If not enough inventory
"""
logger.info(
"[CART_SERVICE] add_to_cart called",
extra={
"vendor_id": vendor_id,
"session_id": session_id,
"product_id": product_id,
"quantity": quantity,
},
)
# Verify product exists and belongs to vendor
product = (
db.query(Product)
.filter(
and_(
Product.id == product_id,
Product.vendor_id == vendor_id,
Product.is_active == True,
)
)
.first()
)
if not product:
logger.error(
"[CART_SERVICE] Product not found",
extra={"product_id": product_id, "vendor_id": vendor_id},
)
raise ProductNotFoundException(product_id=product_id, vendor_id=vendor_id)
logger.info(
f"[CART_SERVICE] Product found: {product.marketplace_product.title}",
extra={
"product_id": product_id,
"product_name": product.marketplace_product.title,
"available_inventory": product.available_inventory,
},
)
# Get current price in cents (use sale_price if available, otherwise regular price)
current_price_cents = (
product.sale_price_cents
or product.price_cents
or 0
)
# Check if item already exists in cart
existing_item = (
db.query(CartItem)
.filter(
and_(
CartItem.vendor_id == vendor_id,
CartItem.session_id == session_id,
CartItem.product_id == product_id,
)
)
.first()
)
if existing_item:
# Update quantity
new_quantity = existing_item.quantity + quantity
# Check inventory for new total quantity
if product.available_inventory < new_quantity:
logger.warning(
"[CART_SERVICE] Insufficient inventory for update",
extra={
"product_id": product_id,
"current_in_cart": existing_item.quantity,
"adding": quantity,
"requested_total": new_quantity,
"available": product.available_inventory,
},
)
raise InsufficientInventoryForCartException(
product_id=product_id,
product_name=product.marketplace_product.title,
requested=new_quantity,
available=product.available_inventory,
)
existing_item.quantity = new_quantity
db.flush()
db.refresh(existing_item)
logger.info(
"[CART_SERVICE] Updated existing cart item",
extra={"cart_item_id": existing_item.id, "new_quantity": new_quantity},
)
return {
"message": "Product quantity updated in cart",
"product_id": product_id,
"quantity": new_quantity,
}
# Check inventory for new item
if product.available_inventory < quantity:
logger.warning(
"[CART_SERVICE] Insufficient inventory",
extra={
"product_id": product_id,
"requested": quantity,
"available": product.available_inventory,
},
)
raise InsufficientInventoryForCartException(
product_id=product_id,
product_name=product.marketplace_product.title,
requested=quantity,
available=product.available_inventory,
)
# Create new cart item (price stored in cents)
cart_item = CartItem(
vendor_id=vendor_id,
session_id=session_id,
product_id=product_id,
quantity=quantity,
price_at_add_cents=current_price_cents,
)
db.add(cart_item)
db.flush()
db.refresh(cart_item)
logger.info(
"[CART_SERVICE] Created new cart item",
extra={
"cart_item_id": cart_item.id,
"quantity": quantity,
"price_cents": current_price_cents,
},
)
return {
"message": "Product added to cart",
"product_id": product_id,
"quantity": quantity,
}
def update_cart_item(
self,
db: Session,
vendor_id: int,
session_id: str,
product_id: int,
quantity: int,
) -> dict:
"""
Update quantity of item in cart.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Session ID
product_id: Product ID
quantity: New quantity (must be >= 1)
Returns:
Success message
Raises:
ValidationException: If quantity < 1
ProductNotFoundException: If product not found
InsufficientInventoryException: If not enough inventory
"""
if quantity < 1:
raise InvalidCartQuantityException(quantity=quantity, min_quantity=1)
# Find cart item
cart_item = (
db.query(CartItem)
.filter(
and_(
CartItem.vendor_id == vendor_id,
CartItem.session_id == session_id,
CartItem.product_id == product_id,
)
)
.first()
)
if not cart_item:
raise CartItemNotFoundException(
product_id=product_id, session_id=session_id
)
# Verify product still exists and is active
product = (
db.query(Product)
.filter(
and_(
Product.id == product_id,
Product.vendor_id == vendor_id,
Product.is_active == True,
)
)
.first()
)
if not product:
raise ProductNotFoundException(str(product_id))
# Check inventory
if product.available_inventory < quantity:
raise InsufficientInventoryForCartException(
product_id=product_id,
product_name=product.marketplace_product.title,
requested=quantity,
available=product.available_inventory,
)
# Update quantity
cart_item.quantity = quantity
db.flush()
db.refresh(cart_item)
logger.info(
"[CART_SERVICE] Updated cart item quantity",
extra={
"cart_item_id": cart_item.id,
"product_id": product_id,
"new_quantity": quantity,
},
)
return {
"message": "Cart updated",
"product_id": product_id,
"quantity": quantity,
}
def remove_from_cart(
self, db: Session, vendor_id: int, session_id: str, product_id: int
) -> dict:
"""
Remove item from cart.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Session ID
product_id: Product ID
Returns:
Success message
Raises:
ProductNotFoundException: If product not in cart
"""
# Find and delete cart item
cart_item = (
db.query(CartItem)
.filter(
and_(
CartItem.vendor_id == vendor_id,
CartItem.session_id == session_id,
CartItem.product_id == product_id,
)
)
.first()
)
if not cart_item:
raise CartItemNotFoundException(
product_id=product_id, session_id=session_id
)
db.delete(cart_item)
logger.info(
"[CART_SERVICE] Removed item from cart",
extra={
"cart_item_id": cart_item.id,
"product_id": product_id,
"session_id": session_id,
},
)
return {"message": "Item removed from cart", "product_id": product_id}
def clear_cart(self, db: Session, vendor_id: int, session_id: str) -> dict:
"""
Clear all items from cart.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Session ID
Returns:
Success message with count of items removed
"""
# Delete all cart items for this session
deleted_count = (
db.query(CartItem)
.filter(
and_(CartItem.vendor_id == vendor_id, CartItem.session_id == session_id)
)
.delete()
)
logger.info(
"[CART_SERVICE] Cleared cart",
extra={
"session_id": session_id,
"vendor_id": vendor_id,
"items_removed": deleted_count,
},
)
return {"message": "Cart cleared", "items_removed": deleted_count}
# Create service instance
cart_service = CartService()

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()

View File

@@ -0,0 +1,13 @@
# app/modules/checkout/__init__.py
"""
Checkout Module
Provides checkout and order creation functionality for storefronts, including:
- Cart to order conversion
- Shipping address handling
- Payment method selection
- Order confirmation
Note: This module is a placeholder for future checkout functionality.
Currently, order creation is handled directly through the orders module.
"""

View File

@@ -0,0 +1,17 @@
# app/modules/checkout/definition.py
"""Checkout module definition."""
from app.modules.base import ModuleDefinition
module = ModuleDefinition(
code="checkout",
name="Checkout",
description="Checkout and order creation for storefronts",
version="1.0.0",
is_self_contained=True,
dependencies=["cart", "orders", "payments", "customers"],
provides_models=False, # Uses Order model from orders module
provides_schemas=True,
provides_services=True,
provides_api_routes=True,
)

View File

@@ -0,0 +1,11 @@
# app/modules/checkout/models/__init__.py
"""
Checkout module models.
Note: The checkout module uses models from other modules:
- Cart model from cart module
- Order model from orders module
- Customer model from customers module
This file exists for consistency with the module structure.
"""

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
# app/modules/checkout/routes/api/storefront.py
"""
Checkout Module - Storefront API Routes
Public endpoints for checkout in storefront.
Uses vendor from middleware context (VendorContextMiddleware).
Note: These endpoints are placeholders for future checkout functionality.
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.checkout.schemas import (
CheckoutRequest,
CheckoutResponse,
CheckoutSessionResponse,
)
from app.modules.checkout.services import checkout_service
from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/checkout/session", response_model=CheckoutSessionResponse)
def create_checkout_session(
checkout_data: CheckoutRequest,
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CheckoutSessionResponse:
"""
Create a checkout session from cart.
Validates the cart and prepares for checkout.
Vendor is automatically determined from request context.
Request Body:
- session_id: Cart session ID
- shipping_address: Shipping address details
- billing_same_as_shipping: Use shipping for billing (default: true)
- billing_address: Billing address if different
- customer_email: Email for order confirmation
- customer_note: Optional note
"""
logger.info(
f"[CHECKOUT_STOREFRONT] create_checkout_session for vendor {vendor.id}",
extra={
"vendor_id": vendor.id,
"session_id": checkout_data.session_id,
},
)
result = checkout_service.create_checkout_session(
db=db,
vendor_id=vendor.id,
session_id=checkout_data.session_id,
)
return CheckoutSessionResponse(**result)
@router.post("/checkout/complete", response_model=CheckoutResponse)
def complete_checkout(
checkout_session_id: str,
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
) -> CheckoutResponse:
"""
Complete checkout and create order.
Converts the cart to an order and processes payment.
Vendor is automatically determined from request context.
Query Parameters:
- checkout_session_id: The checkout session ID from create_checkout_session
"""
logger.info(
f"[CHECKOUT_STOREFRONT] complete_checkout for vendor {vendor.id}",
extra={
"vendor_id": vendor.id,
"checkout_session_id": checkout_session_id,
},
)
result = checkout_service.complete_checkout(
db=db,
vendor_id=vendor.id,
checkout_session_id=checkout_session_id,
)
db.commit()
return CheckoutResponse(**result)

View File

@@ -0,0 +1,14 @@
# app/modules/checkout/schemas/__init__.py
"""Checkout module schemas."""
from app.modules.checkout.schemas.checkout import (
CheckoutRequest,
CheckoutResponse,
CheckoutSessionResponse,
)
__all__ = [
"CheckoutRequest",
"CheckoutResponse",
"CheckoutSessionResponse",
]

View File

@@ -0,0 +1,54 @@
# app/modules/checkout/schemas/checkout.py
"""
Pydantic schemas for checkout operations.
These schemas handle the conversion of a shopping cart into an order.
"""
from pydantic import BaseModel, Field
class ShippingAddress(BaseModel):
"""Shipping address for checkout."""
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
address_line_1: str = Field(..., min_length=1, max_length=255)
address_line_2: str | None = Field(None, max_length=255)
city: str = Field(..., min_length=1, max_length=100)
postal_code: str = Field(..., min_length=1, max_length=20)
country: str = Field(..., min_length=2, max_length=2) # ISO 3166-1 alpha-2
phone: str | None = Field(None, max_length=20)
class CheckoutRequest(BaseModel):
"""Request to initiate checkout."""
session_id: str = Field(..., description="Cart session ID")
shipping_address: ShippingAddress = Field(..., description="Shipping address")
billing_same_as_shipping: bool = Field(
True, description="Use shipping address for billing"
)
billing_address: ShippingAddress | None = Field(
None, description="Billing address (if different from shipping)"
)
customer_email: str = Field(..., description="Customer email for order confirmation")
customer_note: str | None = Field(None, description="Optional customer note")
class CheckoutSessionResponse(BaseModel):
"""Response for checkout session creation."""
checkout_session_id: str = Field(..., description="Checkout session ID")
cart_total: float = Field(..., description="Cart total in euros")
item_count: int = Field(..., description="Number of items in cart")
requires_payment: bool = Field(..., description="Whether payment is required")
class CheckoutResponse(BaseModel):
"""Response for completed checkout."""
order_id: int = Field(..., description="Created order ID")
order_number: str = Field(..., description="Human-readable order number")
total: float = Field(..., description="Order total in euros")
message: str = Field(..., description="Confirmation message")

View File

@@ -0,0 +1,6 @@
# app/modules/checkout/services/__init__.py
"""Checkout module services."""
from app.modules.checkout.services.checkout_service import checkout_service
__all__ = ["checkout_service"]

View File

@@ -0,0 +1,108 @@
# app/modules/checkout/services/checkout_service.py
"""
Checkout service for order creation from cart.
This module provides:
- Cart validation for checkout
- Order creation from cart
- Checkout session management
Note: This is a placeholder service. Full implementation will
integrate with cart, orders, and payments modules.
"""
import logging
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
class CheckoutService:
"""Service for checkout operations."""
def validate_cart_for_checkout(
self, db: Session, vendor_id: int, session_id: str
) -> dict:
"""
Validate cart is ready for checkout.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Cart session ID
Returns:
Validation result with cart summary
Raises:
ValidationException: If cart is not valid for checkout
"""
# TODO: Implement cart validation
# - Check cart is not empty
# - Check all products are still active
# - Check inventory is available
# - Calculate totals
logger.info(
"[CHECKOUT_SERVICE] validate_cart_for_checkout",
extra={"vendor_id": vendor_id, "session_id": session_id},
)
raise NotImplementedError("Checkout service not yet implemented")
def create_checkout_session(
self, db: Session, vendor_id: int, session_id: str
) -> dict:
"""
Create a checkout session from cart.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Cart session ID
Returns:
Checkout session data
"""
# TODO: Implement checkout session creation
logger.info(
"[CHECKOUT_SERVICE] create_checkout_session",
extra={"vendor_id": vendor_id, "session_id": session_id},
)
raise NotImplementedError("Checkout service not yet implemented")
def complete_checkout(
self,
db: Session,
vendor_id: int,
checkout_session_id: str,
customer_id: int | None = None,
) -> dict:
"""
Complete checkout and create order.
Args:
db: Database session
vendor_id: Vendor ID
checkout_session_id: Checkout session ID
customer_id: Optional customer ID (for registered users)
Returns:
Created order data
"""
# TODO: Implement checkout completion
# - Convert cart to order
# - Clear cart
# - Send confirmation email
logger.info(
"[CHECKOUT_SERVICE] complete_checkout",
extra={
"vendor_id": vendor_id,
"checkout_session_id": checkout_session_id,
"customer_id": customer_id,
},
)
raise NotImplementedError("Checkout service not yet implemented")
# Create service instance
checkout_service = CheckoutService()

View File

@@ -310,9 +310,9 @@ After migrated to `app/modules/cart/services/cart_service.py`.
## Execution Order
1. **Phase 1** - Add architecture rule (enables detection)
2. **Phase 2** - Rename shop → storefront (terminology)
3. **Phase 3** - Create new modules (cart, checkout, catalog)
1. **Phase 1** - Add architecture rule (enables detection) ✅ COMPLETE
2. **Phase 2** - Rename shop → storefront (terminology) ✅ COMPLETE
3. **Phase 3** - Create new modules (cart, checkout, catalog) ✅ COMPLETE
4. **Phase 4** - Move routes to modules
5. **Phase 5** - Fix direct model imports
6. **Phase 6** - Delete legacy files