From 309104292da265cca95eee6d5672ffc62ea763f4 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Thu, 29 Jan 2026 23:09:11 +0100 Subject: [PATCH] refactor(cleanup): delete legacy storefront routes, convert cart to re-exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of storefront restructure plan - delete legacy files and convert remaining cart files to re-exports. Deleted legacy storefront route files (now served from modules): - app/api/v1/storefront/auth.py (→ customers module) - app/api/v1/storefront/profile.py (→ customers module) - app/api/v1/storefront/addresses.py (→ customers module) - app/api/v1/storefront/carts.py (→ cart module) - app/api/v1/storefront/products.py (→ catalog module) - app/api/v1/storefront/orders.py (→ orders/checkout modules) - app/api/v1/storefront/messages.py (→ messaging module) Converted legacy cart files to re-exports: - models/schema/cart.py → app.modules.cart.schemas - models/database/cart.py → app.modules.cart.models - app/services/cart_service.py → app.modules.cart.services This reduces API-007 violations from 81 to 69 (remaining violations are in admin/vendor routes - separate migration effort). Co-Authored-By: Claude Opus 4.5 --- app/api/v1/storefront/addresses.py | 269 --------- app/api/v1/storefront/auth.py | 376 ------------ app/api/v1/storefront/carts.py | 238 -------- app/api/v1/storefront/messages.py | 541 ------------------ app/api/v1/storefront/orders.py | 330 ----------- app/api/v1/storefront/products.py | 171 ------ app/api/v1/storefront/profile.py | 161 ------ app/services/cart_service.py | 452 +-------------- .../PLAN_storefront-module-restructure.md | 2 +- models/database/cart.py | 81 +-- models/schema/cart.py | 108 +--- 11 files changed, 37 insertions(+), 2692 deletions(-) delete mode 100644 app/api/v1/storefront/addresses.py delete mode 100644 app/api/v1/storefront/auth.py delete mode 100644 app/api/v1/storefront/carts.py delete mode 100644 app/api/v1/storefront/messages.py delete mode 100644 app/api/v1/storefront/orders.py delete mode 100644 app/api/v1/storefront/products.py delete mode 100644 app/api/v1/storefront/profile.py diff --git a/app/api/v1/storefront/addresses.py b/app/api/v1/storefront/addresses.py deleted file mode 100644 index 07267e70..00000000 --- a/app/api/v1/storefront/addresses.py +++ /dev/null @@ -1,269 +0,0 @@ -# app/api/v1/shop/addresses.py -""" -Shop Addresses API (Customer authenticated) - -Endpoints for managing customer addresses in shop frontend. -Uses vendor from request.state (injected by VendorContextMiddleware). -Requires customer authentication. -""" - -import logging - -from fastapi import APIRouter, Depends, Path, Request -from sqlalchemy.orm import Session - -from app.api.deps import get_current_customer_api -from app.core.database import get_db -from app.exceptions import VendorNotFoundException -from app.services.customer_address_service import customer_address_service -from models.database.customer import Customer -from models.schema.customer import ( - CustomerAddressCreate, - CustomerAddressListResponse, - CustomerAddressResponse, - CustomerAddressUpdate, -) - -router = APIRouter() -logger = logging.getLogger(__name__) - - -@router.get("/addresses", response_model=CustomerAddressListResponse) # authenticated -def list_addresses( - request: Request, - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - List all addresses for authenticated customer. - - Vendor is automatically determined from request context. - Returns all addresses sorted by default first, then by creation date. - """ - vendor = getattr(request.state, "vendor", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] list_addresses for customer {customer.id}", - extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, - "customer_id": customer.id, - }, - ) - - addresses = customer_address_service.list_addresses( - db=db, vendor_id=vendor.id, customer_id=customer.id - ) - - return CustomerAddressListResponse( - addresses=[CustomerAddressResponse.model_validate(a) for a in addresses], - total=len(addresses), - ) - - -@router.get("/addresses/{address_id}", response_model=CustomerAddressResponse) -def get_address( - request: Request, - address_id: int = Path(..., description="Address ID", gt=0), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Get specific address by ID. - - Vendor is automatically determined from request context. - Customer can only access their own addresses. - """ - vendor = getattr(request.state, "vendor", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] get_address {address_id} for customer {customer.id}", - extra={ - "vendor_id": vendor.id, - "customer_id": customer.id, - "address_id": address_id, - }, - ) - - address = customer_address_service.get_address( - db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id - ) - - return CustomerAddressResponse.model_validate(address) - - -@router.post("/addresses", response_model=CustomerAddressResponse, status_code=201) -def create_address( - request: Request, - address_data: CustomerAddressCreate, - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Create new address for authenticated customer. - - Vendor is automatically determined from request context. - Maximum 10 addresses per customer. - If is_default=True, clears default flag on other addresses of same type. - """ - vendor = getattr(request.state, "vendor", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] create_address for customer {customer.id}", - extra={ - "vendor_id": vendor.id, - "customer_id": customer.id, - "address_type": address_data.address_type, - }, - ) - - address = customer_address_service.create_address( - db=db, - vendor_id=vendor.id, - customer_id=customer.id, - address_data=address_data, - ) - db.commit() - - logger.info( - f"Created address {address.id} for customer {customer.id} " - f"(type={address_data.address_type})", - extra={ - "address_id": address.id, - "customer_id": customer.id, - "address_type": address_data.address_type, - }, - ) - - return CustomerAddressResponse.model_validate(address) - - -@router.put("/addresses/{address_id}", response_model=CustomerAddressResponse) -def update_address( - request: Request, - address_data: CustomerAddressUpdate, - address_id: int = Path(..., description="Address ID", gt=0), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Update existing address. - - Vendor is automatically determined from request context. - Customer can only update their own addresses. - If is_default=True, clears default flag on other addresses of same type. - """ - vendor = getattr(request.state, "vendor", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] update_address {address_id} for customer {customer.id}", - extra={ - "vendor_id": vendor.id, - "customer_id": customer.id, - "address_id": address_id, - }, - ) - - address = customer_address_service.update_address( - db=db, - vendor_id=vendor.id, - customer_id=customer.id, - address_id=address_id, - address_data=address_data, - ) - db.commit() - - logger.info( - f"Updated address {address_id} for customer {customer.id}", - extra={"address_id": address_id, "customer_id": customer.id}, - ) - - return CustomerAddressResponse.model_validate(address) - - -@router.delete("/addresses/{address_id}", status_code=204) -def delete_address( - request: Request, - address_id: int = Path(..., description="Address ID", gt=0), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Delete address. - - Vendor is automatically determined from request context. - Customer can only delete their own addresses. - """ - vendor = getattr(request.state, "vendor", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] delete_address {address_id} for customer {customer.id}", - extra={ - "vendor_id": vendor.id, - "customer_id": customer.id, - "address_id": address_id, - }, - ) - - customer_address_service.delete_address( - db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id - ) - db.commit() - - logger.info( - f"Deleted address {address_id} for customer {customer.id}", - extra={"address_id": address_id, "customer_id": customer.id}, - ) - - -@router.put("/addresses/{address_id}/default", response_model=CustomerAddressResponse) -def set_address_default( - request: Request, - address_id: int = Path(..., description="Address ID", gt=0), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Set address as default for its type. - - Vendor is automatically determined from request context. - Clears default flag on other addresses of the same type. - """ - vendor = getattr(request.state, "vendor", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] set_address_default {address_id} for customer {customer.id}", - extra={ - "vendor_id": vendor.id, - "customer_id": customer.id, - "address_id": address_id, - }, - ) - - address = customer_address_service.set_default( - db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id - ) - db.commit() - - logger.info( - f"Set address {address_id} as default for customer {customer.id}", - extra={ - "address_id": address_id, - "customer_id": customer.id, - "address_type": address.address_type, - }, - ) - - return CustomerAddressResponse.model_validate(address) diff --git a/app/api/v1/storefront/auth.py b/app/api/v1/storefront/auth.py deleted file mode 100644 index e623bbaf..00000000 --- a/app/api/v1/storefront/auth.py +++ /dev/null @@ -1,376 +0,0 @@ -# app/api/v1/shop/auth.py -""" -Shop Authentication API (Public) - -Public endpoints for customer authentication in shop frontend. -Uses vendor from request.state (injected by VendorContextMiddleware). - -Implements dual token storage with path restriction: -- Sets HTTP-only cookie with path=/shop (restricted to shop routes only) -- Returns token in response for localStorage (API calls) - -This prevents: -- Customer cookies from being sent to admin or vendor routes -- Cross-context authentication confusion -""" - -import logging - -from fastapi import APIRouter, Depends, HTTPException, Request, Response -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from app.core.database import get_db -from app.core.environment import should_use_secure_cookies -from app.exceptions import VendorNotFoundException -from app.services.customer_service import customer_service -from app.services.email_service import EmailService -from models.database.customer import Customer -from models.database.password_reset_token import PasswordResetToken -from models.schema.auth import ( - LogoutResponse, - PasswordResetRequestResponse, - PasswordResetResponse, - UserLogin, -) -from models.schema.customer import CustomerRegister, CustomerResponse - -router = APIRouter() -logger = logging.getLogger(__name__) - - -# Response model for customer login -class CustomerLoginResponse(BaseModel): - """Customer login response with token and customer data.""" - - access_token: str - token_type: str - expires_in: int - user: CustomerResponse # Use CustomerResponse instead of UserResponse - - -@router.post("/auth/register", response_model=CustomerResponse) -def register_customer( - request: Request, customer_data: CustomerRegister, db: Session = Depends(get_db) -): - """ - Register a new customer for current vendor. - - Vendor is automatically determined from request context. - Customer accounts are vendor-scoped - each vendor has independent customers. - Same email can be used for different vendors. - - Request Body: - - email: Customer email address - - password: Customer password - - first_name: Customer first name - - last_name: Customer last name - - phone: Customer phone number (optional) - """ - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] register_customer for vendor {vendor.subdomain}", - extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, - "email": customer_data.email, - }, - ) - - # Create customer account - customer = customer_service.register_customer( - db=db, vendor_id=vendor.id, customer_data=customer_data - ) - db.commit() - - logger.info( - f"New customer registered: {customer.email} for vendor {vendor.subdomain}", - extra={ - "customer_id": customer.id, - "vendor_id": vendor.id, - "email": customer.email, - }, - ) - - return CustomerResponse.model_validate(customer) - - -@router.post("/auth/login", response_model=CustomerLoginResponse) -def customer_login( - request: Request, - user_credentials: UserLogin, - response: Response, - db: Session = Depends(get_db), -): - """ - Customer login for current vendor. - - Vendor is automatically determined from request context. - Authenticates customer and returns JWT token. - Customer must belong to the specified vendor. - - Sets token in two places: - 1. HTTP-only cookie with path=/shop (for browser page navigation) - 2. Response body (for localStorage and API calls) - - The cookie is restricted to /shop/* routes only to prevent - it from being sent to admin or vendor routes. - - Request Body: - - email_or_username: Customer email or username - - password: Customer password - """ - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] customer_login for vendor {vendor.subdomain}", - extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, - "email_or_username": user_credentials.email_or_username, - }, - ) - - # Authenticate customer - login_result = customer_service.login_customer( - db=db, vendor_id=vendor.id, credentials=user_credentials - ) - - logger.info( - f"Customer login successful: {login_result['customer'].email} for vendor {vendor.subdomain}", - extra={ - "customer_id": login_result["customer"].id, - "vendor_id": vendor.id, - "email": login_result["customer"].email, - }, - ) - - # Calculate cookie path based on vendor access method - vendor_context = getattr(request.state, "vendor_context", None) - access_method = ( - vendor_context.get("detection_method", "unknown") - if vendor_context - else "unknown" - ) - - cookie_path = "/shop" # Default for domain/subdomain access - if access_method == "path": - # For path-based access like /vendors/wizamart/shop - full_prefix = ( - vendor_context.get("full_prefix", "/vendor/") - if vendor_context - else "/vendor/" - ) - cookie_path = f"{full_prefix}{vendor.subdomain}/shop" - - # Set HTTP-only cookie for browser navigation - # Cookie path matches the vendor's shop routes - response.set_cookie( - key="customer_token", - value=login_result["token_data"]["access_token"], - httponly=True, # JavaScript cannot access (XSS protection) - secure=should_use_secure_cookies(), # HTTPS only in production/staging - samesite="lax", # CSRF protection - max_age=login_result["token_data"]["expires_in"], # Match JWT expiry - path=cookie_path, # Matches vendor's shop routes - ) - - logger.debug( - f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry " - f"(path={cookie_path}, httponly=True, secure={should_use_secure_cookies()})", - extra={ - "expires_in": login_result["token_data"]["expires_in"], - "secure": should_use_secure_cookies(), - "cookie_path": cookie_path, - }, - ) - - # Return full login response - return CustomerLoginResponse( - access_token=login_result["token_data"]["access_token"], - token_type=login_result["token_data"]["token_type"], - expires_in=login_result["token_data"]["expires_in"], - user=CustomerResponse.model_validate(login_result["customer"]), - ) - - -@router.post("/auth/logout", response_model=LogoutResponse) -def customer_logout(request: Request, response: Response): - """ - Customer logout for current vendor. - - Vendor is automatically determined from request context. - Clears the customer_token cookie. - Client should also remove token from localStorage. - """ - # Get vendor from middleware (for logging) - vendor = getattr(request.state, "vendor", None) - - logger.info( - f"Customer logout for vendor {vendor.subdomain if vendor else 'unknown'}", - extra={ - "vendor_id": vendor.id if vendor else None, - "vendor_code": vendor.subdomain if vendor else None, - }, - ) - - # Calculate cookie path based on vendor access method (must match login) - vendor_context = getattr(request.state, "vendor_context", None) - access_method = ( - vendor_context.get("detection_method", "unknown") - if vendor_context - else "unknown" - ) - - cookie_path = "/shop" # Default for domain/subdomain access - if access_method == "path" and vendor: - # For path-based access like /vendors/wizamart/shop - full_prefix = ( - vendor_context.get("full_prefix", "/vendor/") - if vendor_context - else "/vendor/" - ) - cookie_path = f"{full_prefix}{vendor.subdomain}/shop" - - # Clear the cookie (must match path used when setting) - response.delete_cookie( - key="customer_token", - path=cookie_path, - ) - - logger.debug(f"Deleted customer_token cookie (path={cookie_path})") - - return LogoutResponse(message="Logged out successfully") - - -@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse) -def forgot_password(request: Request, email: str, db: Session = Depends(get_db)): - """ - Request password reset for customer. - - Vendor is automatically determined from request context. - Sends password reset email to customer if account exists. - - Request Body: - - email: Customer email address - """ - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] forgot_password for vendor {vendor.subdomain}", - extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, - "email": email, - }, - ) - - # Look up customer by email (vendor-scoped) - customer = customer_service.get_customer_for_password_reset(db, vendor.id, email) - - # If customer exists, generate token and send email - if customer: - try: - # Generate reset token (returns plaintext token) - plaintext_token = PasswordResetToken.create_for_customer(db, customer.id) - - # Build reset link - # Use request host to construct the URL - scheme = "https" if should_use_secure_cookies() else "http" - host = request.headers.get("host", "localhost") - reset_link = f"{scheme}://{host}/shop/account/reset-password?token={plaintext_token}" - - # Send password reset email - email_service = EmailService(db) - email_service.send_template( - template_code="password_reset", - to_email=customer.email, - to_name=customer.full_name, - language=customer.preferred_language or "en", - variables={ - "customer_name": customer.first_name or customer.full_name, - "reset_link": reset_link, - "expiry_hours": str(PasswordResetToken.TOKEN_EXPIRY_HOURS), - }, - vendor_id=vendor.id, - related_type="customer", - related_id=customer.id, - ) - - db.commit() - logger.info( - f"Password reset email sent to {email} (vendor: {vendor.subdomain})" - ) - except Exception as e: - db.rollback() - logger.error(f"Failed to send password reset email: {e}") - # Don't reveal the error to the user for security - else: - # Log but don't reveal that email doesn't exist - logger.info( - f"Password reset requested for non-existent email {email} (vendor: {vendor.subdomain})" - ) - - # Always return the same message (don't reveal if email exists) - return PasswordResetRequestResponse( - message="If an account exists with this email, a password reset link has been sent." - ) - - -@router.post("/auth/reset-password", response_model=PasswordResetResponse) -def reset_password( - request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db) -): - """ - Reset customer password using reset token. - - Vendor is automatically determined from request context. - - Request Body: - - reset_token: Password reset token from email - - new_password: New password (minimum 8 characters) - """ - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] reset_password for vendor {vendor.subdomain}", - extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, - }, - ) - - # Validate and reset password using service - customer = customer_service.validate_and_reset_password( - db=db, - vendor_id=vendor.id, - reset_token=reset_token, - new_password=new_password, - ) - - db.commit() - - logger.info( - f"Password reset completed for customer {customer.id} (vendor: {vendor.subdomain})" - ) - - return PasswordResetResponse( - message="Password reset successfully. You can now log in with your new password." - ) diff --git a/app/api/v1/storefront/carts.py b/app/api/v1/storefront/carts.py deleted file mode 100644 index 0dc2387c..00000000 --- a/app/api/v1/storefront/carts.py +++ /dev/null @@ -1,238 +0,0 @@ -# app/api/v1/shop/carts.py -""" -Shop Shopping Cart API (Public) - -Public endpoints for managing shopping cart in shop frontend. -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.services.cart_service import cart_service -from middleware.vendor_context import require_vendor_context -from models.database.vendor import Vendor -from models.schema.cart import ( - AddToCartRequest, - CartOperationResponse, - CartResponse, - ClearCartResponse, - UpdateCartItemRequest, -) - -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"[SHOP_API] 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"[SHOP_API] 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"[SHOP_API] 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"[SHOP_API] 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"[SHOP_API] 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"[SHOP_API] 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"[SHOP_API] 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) diff --git a/app/api/v1/storefront/messages.py b/app/api/v1/storefront/messages.py deleted file mode 100644 index 87c6fd6c..00000000 --- a/app/api/v1/storefront/messages.py +++ /dev/null @@ -1,541 +0,0 @@ -# app/api/v1/shop/messages.py -""" -Shop Messages API (Customer authenticated) - -Endpoints for customer messaging in shop frontend. -Uses vendor from request.state (injected by VendorContextMiddleware). -Requires customer authentication. - -Customers can only: -- View their own vendor_customer conversations -- Reply to existing conversations -- Mark conversations as read -""" - -import logging -from typing import List, Optional - -from fastapi import APIRouter, Depends, File, Form, Path, Query, Request, UploadFile -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from app.api.deps import get_current_customer_api -from app.core.database import get_db -from app.exceptions import ( - AttachmentNotFoundException, - ConversationClosedException, - ConversationNotFoundException, - VendorNotFoundException, -) -from app.services.message_attachment_service import message_attachment_service -from app.services.messaging_service import messaging_service -from models.database.customer import Customer -from models.database.message import ConversationType, ParticipantType -from models.schema.message import ( - ConversationDetailResponse, - ConversationListResponse, - ConversationSummary, - MessageResponse, - UnreadCountResponse, -) - -router = APIRouter() -logger = logging.getLogger(__name__) - - -# ============================================================================ -# Response Models -# ============================================================================ - - -class SendMessageResponse(BaseModel): - """Response for send message.""" - - success: bool - message: MessageResponse - - -# ============================================================================ -# API Endpoints -# ============================================================================ - - -@router.get("/messages", response_model=ConversationListResponse) # authenticated -def list_conversations( - request: Request, - skip: int = Query(0, ge=0), - limit: int = Query(50, ge=1, le=100), - status: Optional[str] = Query(None, regex="^(open|closed)$"), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - List conversations for authenticated customer. - - Customers only see their vendor_customer conversations. - - Query Parameters: - - skip: Pagination offset - - limit: Max items to return - - status: Filter by open/closed - """ - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] list_conversations for customer {customer.id}", - extra={ - "vendor_id": vendor.id, - "customer_id": customer.id, - "skip": skip, - "limit": limit, - "status": status, - }, - ) - - # Build filter - customers only see vendor_customer conversations - is_closed = None - if status == "open": - is_closed = False - elif status == "closed": - is_closed = True - - # Get conversations where customer is a participant - conversations, total = messaging_service.list_conversations( - db=db, - participant_type=ParticipantType.CUSTOMER, - participant_id=customer.id, - vendor_id=vendor.id, - conversation_type=ConversationType.VENDOR_CUSTOMER, - is_closed=is_closed, - skip=skip, - limit=limit, - ) - - # Convert to summaries - summaries = [] - for conv, unread in conversations: - summaries.append( - ConversationSummary( - id=conv.id, - subject=conv.subject, - conversation_type=conv.conversation_type.value, - is_closed=conv.is_closed, - last_message_at=conv.last_message_at, - message_count=conv.message_count, - unread_count=unread, - other_participant_name=_get_other_participant_name(conv, customer.id), - ) - ) - - return ConversationListResponse( - conversations=summaries, - total=total, - skip=skip, - limit=limit, - ) - - -@router.get("/messages/unread-count", response_model=UnreadCountResponse) -def get_unread_count( - request: Request, - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Get total unread message count for header badge. - """ - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - count = messaging_service.get_unread_count( - db=db, - participant_type=ParticipantType.CUSTOMER, - participant_id=customer.id, - vendor_id=vendor.id, - ) - - return UnreadCountResponse(unread_count=count) - - -@router.get("/messages/{conversation_id}", response_model=ConversationDetailResponse) -def get_conversation( - request: Request, - conversation_id: int = Path(..., description="Conversation ID", gt=0), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Get conversation detail with messages. - - Validates that customer is a participant. - Automatically marks conversation as read. - """ - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] get_conversation {conversation_id} for customer {customer.id}", - extra={ - "vendor_id": vendor.id, - "customer_id": customer.id, - "conversation_id": conversation_id, - }, - ) - - # Get conversation with access check - conversation = messaging_service.get_conversation( - db=db, - conversation_id=conversation_id, - participant_type=ParticipantType.CUSTOMER, - participant_id=customer.id, - vendor_id=vendor.id, - ) - - if not conversation: - raise ConversationNotFoundException(str(conversation_id)) - - # Mark as read - messaging_service.mark_conversation_read( - db=db, - conversation_id=conversation_id, - participant_type=ParticipantType.CUSTOMER, - participant_id=customer.id, - ) - - # Build response - messages = [] - for msg in conversation.messages: - if msg.is_deleted: - continue - messages.append( - MessageResponse( - id=msg.id, - content=msg.content, - sender_type=msg.sender_type.value, - sender_id=msg.sender_id, - sender_name=_get_sender_name(msg), - is_system_message=msg.is_system_message, - attachments=[ - { - "id": att.id, - "filename": att.original_filename, - "file_size": att.file_size, - "mime_type": att.mime_type, - "is_image": att.is_image, - "download_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}", - "thumbnail_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}/thumbnail" - if att.thumbnail_path - else None, - } - for att in msg.attachments - ], - created_at=msg.created_at, - ) - ) - - return ConversationDetailResponse( - id=conversation.id, - subject=conversation.subject, - conversation_type=conversation.conversation_type.value, - is_closed=conversation.is_closed, - closed_at=conversation.closed_at, - last_message_at=conversation.last_message_at, - message_count=conversation.message_count, - messages=messages, - other_participant_name=_get_other_participant_name(conversation, customer.id), - ) - - -@router.post("/messages/{conversation_id}/messages", response_model=SendMessageResponse) -async def send_message( - request: Request, - conversation_id: int = Path(..., description="Conversation ID", gt=0), - content: str = Form(..., min_length=1, max_length=10000), - attachments: List[UploadFile] = File(default=[]), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Send a message in a conversation. - - Validates that customer is a participant. - Supports file attachments. - """ - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] send_message in {conversation_id} from customer {customer.id}", - extra={ - "vendor_id": vendor.id, - "customer_id": customer.id, - "conversation_id": conversation_id, - "attachment_count": len(attachments), - }, - ) - - # Verify conversation exists and customer has access - conversation = messaging_service.get_conversation( - db=db, - conversation_id=conversation_id, - participant_type=ParticipantType.CUSTOMER, - participant_id=customer.id, - vendor_id=vendor.id, - ) - - if not conversation: - raise ConversationNotFoundException(str(conversation_id)) - - # Check if conversation is closed - if conversation.is_closed: - raise ConversationClosedException(conversation_id) - - # Process attachments - attachment_data = [] - for upload_file in attachments: - if upload_file.filename: - file_data = await message_attachment_service.validate_and_store( - db=db, - upload_file=upload_file, - conversation_id=conversation_id, - ) - attachment_data.append(file_data) - - # Send message - message = messaging_service.send_message( - db=db, - conversation_id=conversation_id, - sender_type=ParticipantType.CUSTOMER, - sender_id=customer.id, - content=content, - attachments=attachment_data, - ) - - logger.info( - f"[SHOP_API] Message sent in conversation {conversation_id}", - extra={ - "message_id": message.id, - "customer_id": customer.id, - "vendor_id": vendor.id, - }, - ) - - return SendMessageResponse( - success=True, - message=MessageResponse( - id=message.id, - content=message.content, - sender_type=message.sender_type.value, - sender_id=message.sender_id, - sender_name=_get_sender_name(message), - is_system_message=message.is_system_message, - attachments=[ - { - "id": att.id, - "filename": att.original_filename, - "file_size": att.file_size, - "mime_type": att.mime_type, - "is_image": att.is_image, - "download_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}", - "thumbnail_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}/thumbnail" - if att.thumbnail_path - else None, - } - for att in message.attachments - ], - created_at=message.created_at, - ), - ) - - -@router.put("/messages/{conversation_id}/read", response_model=dict) -def mark_as_read( - request: Request, - conversation_id: int = Path(..., description="Conversation ID", gt=0), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """Mark conversation as read.""" - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - # Verify access - conversation = messaging_service.get_conversation( - db=db, - conversation_id=conversation_id, - participant_type=ParticipantType.CUSTOMER, - participant_id=customer.id, - vendor_id=vendor.id, - ) - - if not conversation: - raise ConversationNotFoundException(str(conversation_id)) - - messaging_service.mark_conversation_read( - db=db, - conversation_id=conversation_id, - participant_type=ParticipantType.CUSTOMER, - participant_id=customer.id, - ) - - return {"success": True} - - -@router.get("/messages/{conversation_id}/attachments/{attachment_id}") -async def download_attachment( - request: Request, - conversation_id: int = Path(..., description="Conversation ID", gt=0), - attachment_id: int = Path(..., description="Attachment ID", gt=0), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Download a message attachment. - - Validates that customer has access to the conversation. - """ - from fastapi.responses import FileResponse - - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - # Verify access - conversation = messaging_service.get_conversation( - db=db, - conversation_id=conversation_id, - participant_type=ParticipantType.CUSTOMER, - participant_id=customer.id, - vendor_id=vendor.id, - ) - - if not conversation: - raise ConversationNotFoundException(str(conversation_id)) - - # Find attachment - attachment = message_attachment_service.get_attachment( - db=db, - attachment_id=attachment_id, - conversation_id=conversation_id, - ) - - if not attachment: - raise AttachmentNotFoundException(attachment_id) - - return FileResponse( - path=attachment.file_path, - filename=attachment.original_filename, - media_type=attachment.mime_type, - ) - - -@router.get("/messages/{conversation_id}/attachments/{attachment_id}/thumbnail") -async def get_attachment_thumbnail( - request: Request, - conversation_id: int = Path(..., description="Conversation ID", gt=0), - attachment_id: int = Path(..., description="Attachment ID", gt=0), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Get thumbnail for an image attachment. - - Validates that customer has access to the conversation. - """ - from fastapi.responses import FileResponse - - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - # Verify access - conversation = messaging_service.get_conversation( - db=db, - conversation_id=conversation_id, - participant_type=ParticipantType.CUSTOMER, - participant_id=customer.id, - vendor_id=vendor.id, - ) - - if not conversation: - raise ConversationNotFoundException(str(conversation_id)) - - # Find attachment - attachment = message_attachment_service.get_attachment( - db=db, - attachment_id=attachment_id, - conversation_id=conversation_id, - ) - - if not attachment or not attachment.thumbnail_path: - raise AttachmentNotFoundException(f"{attachment_id}/thumbnail") - - return FileResponse( - path=attachment.thumbnail_path, - media_type="image/jpeg", - ) - - -# ============================================================================ -# Helper Functions -# ============================================================================ - - -def _get_other_participant_name(conversation, customer_id: int) -> str: - """Get the name of the other participant (the vendor user).""" - for participant in conversation.participants: - if participant.participant_type == ParticipantType.VENDOR: - # Get vendor user name - from models.database.user import User - - user = ( - User.query.filter_by(id=participant.participant_id).first() - if hasattr(User, "query") - else None - ) - if user: - return f"{user.first_name} {user.last_name}" - return "Shop Support" - return "Shop Support" - - -def _get_sender_name(message) -> str: - """Get sender name for a message.""" - if message.sender_type == ParticipantType.CUSTOMER: - from models.database.customer import Customer - - customer = ( - Customer.query.filter_by(id=message.sender_id).first() - if hasattr(Customer, "query") - else None - ) - if customer: - return f"{customer.first_name} {customer.last_name}" - return "Customer" - elif message.sender_type == ParticipantType.VENDOR: - from models.database.user import User - - user = ( - User.query.filter_by(id=message.sender_id).first() - if hasattr(User, "query") - else None - ) - if user: - return f"{user.first_name} {user.last_name}" - return "Shop Support" - elif message.sender_type == ParticipantType.ADMIN: - return "Platform Support" - return "Unknown" diff --git a/app/api/v1/storefront/orders.py b/app/api/v1/storefront/orders.py deleted file mode 100644 index 0f6e4f76..00000000 --- a/app/api/v1/storefront/orders.py +++ /dev/null @@ -1,330 +0,0 @@ -# app/api/v1/shop/orders.py -""" -Shop Orders API (Customer authenticated) - -Endpoints for managing customer orders in shop frontend. -Uses vendor from request.state (injected by VendorContextMiddleware). -Requires customer authentication - get_current_customer_api validates -that customer token vendor_id matches the URL vendor. - -Customer Context: get_current_customer_api returns Customer directly -(not User), with vendor validation already performed. -""" - -import logging -from datetime import UTC, datetime -from pathlib import Path as FilePath - -from fastapi import APIRouter, Depends, Path, Query, Request -from fastapi.responses import FileResponse -from sqlalchemy.orm import Session - -from app.api.deps import get_current_customer_api -from app.core.database import get_db -from app.exceptions import VendorNotFoundException -from app.exceptions.invoice import InvoicePDFNotFoundException -from app.services.cart_service import cart_service -from app.services.email_service import EmailService -from app.services.invoice_service import invoice_service -from app.services.order_service import order_service -from app.utils.money import cents_to_euros -from models.database.customer import Customer -from models.schema.order import ( - OrderCreate, - OrderDetailResponse, - OrderListResponse, - OrderResponse, -) - -router = APIRouter() -logger = logging.getLogger(__name__) - - -@router.post("/orders", response_model=OrderResponse) # authenticated -def place_order( - request: Request, - order_data: OrderCreate, - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Place a new order for current vendor. - - Vendor is automatically determined from request context. - Customer must be authenticated to place an order. - Creates an order from the customer's cart. - - Request Body: - - Order data including shipping address, payment method, etc. - """ - # Get vendor from middleware (already validated by get_current_customer_api) - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] place_order for customer {customer.id}", - extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, - "customer_id": customer.id, - }, - ) - - # Create order - order = order_service.create_order( - db=db, vendor_id=vendor.id, order_data=order_data - ) - db.commit() - - logger.info( - f"Order {order.order_number} placed for vendor {vendor.subdomain}, " - f"total: €{order.total_amount:.2f}", - extra={ - "order_id": order.id, - "order_number": order.order_number, - "customer_id": customer.id, - "total_amount": float(order.total_amount), - }, - ) - - # Update customer stats - customer.total_orders = (customer.total_orders or 0) + 1 - customer.total_spent = (customer.total_spent or 0) + order.total_amount - customer.last_order_date = datetime.now(UTC) - db.flush() - - logger.debug( - f"Updated customer stats: total_orders={customer.total_orders}, " - f"total_spent={customer.total_spent}" - ) - - # Clear cart (get session_id from request cookies or headers) - session_id = request.cookies.get("cart_session_id") or request.headers.get( - "X-Cart-Session-Id" - ) - if session_id: - try: - cart_service.clear_cart(db, vendor.id, session_id) - logger.debug(f"Cleared cart for session {session_id}") - except Exception as e: - logger.warning(f"Failed to clear cart: {e}") - - # Send order confirmation email - try: - email_service = EmailService(db) - email_service.send_template( - template_code="order_confirmation", - to_email=customer.email, - to_name=customer.full_name, - language=customer.preferred_language or "en", - variables={ - "customer_name": customer.first_name or customer.full_name, - "order_number": order.order_number, - "order_total": f"€{order.total_amount:.2f}", - "order_items_count": len(order.items), - "order_date": order.order_date.strftime("%d.%m.%Y") - if order.order_date - else "", - "shipping_address": f"{order.ship_address_line_1}, {order.ship_postal_code} {order.ship_city}", - }, - vendor_id=vendor.id, - related_type="order", - related_id=order.id, - ) - logger.info(f"Sent order confirmation email to {customer.email}") - except Exception as e: - logger.warning(f"Failed to send order confirmation email: {e}") - - return OrderResponse.model_validate(order) - - -@router.get("/orders", response_model=OrderListResponse) -def get_my_orders( - request: Request, - skip: int = Query(0, ge=0), - limit: int = Query(50, ge=1, le=100), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Get order history for authenticated customer. - - Vendor is automatically determined from request context. - Returns all orders placed by the authenticated customer. - - Query Parameters: - - skip: Number of orders to skip (pagination) - - limit: Maximum number of orders to return - """ - # Get vendor from middleware (already validated by get_current_customer_api) - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] get_my_orders for customer {customer.id}", - extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, - "customer_id": customer.id, - "skip": skip, - "limit": limit, - }, - ) - - # Get orders - orders, total = order_service.get_customer_orders( - db=db, vendor_id=vendor.id, customer_id=customer.id, skip=skip, limit=limit - ) - - return OrderListResponse( - orders=[OrderResponse.model_validate(o) for o in orders], - total=total, - skip=skip, - limit=limit, - ) - - -@router.get("/orders/{order_id}", response_model=OrderDetailResponse) -def get_order_details( - request: Request, - order_id: int = Path(..., description="Order ID", gt=0), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Get detailed order information for authenticated customer. - - Vendor is automatically determined from request context. - Customer can only view their own orders. - - Path Parameters: - - order_id: ID of the order to retrieve - """ - # Get vendor from middleware (already validated by get_current_customer_api) - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] get_order_details: order {order_id}", - extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, - "customer_id": customer.id, - "order_id": order_id, - }, - ) - - # Get order - order = order_service.get_order(db=db, vendor_id=vendor.id, order_id=order_id) - - # Verify order belongs to customer - if order.customer_id != customer.id: - from app.exceptions import OrderNotFoundException - - raise OrderNotFoundException(str(order_id)) - - return OrderDetailResponse.model_validate(order) - - -@router.get("/orders/{order_id}/invoice") -def download_order_invoice( - request: Request, - order_id: int = Path(..., description="Order ID", gt=0), - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Download invoice PDF for a customer's order. - - Vendor is automatically determined from request context. - Customer can only download invoices for their own orders. - Invoice is auto-generated if it doesn't exist. - - Path Parameters: - - order_id: ID of the order to get invoice for - """ - from app.exceptions import OrderNotFoundException - - # Get vendor from middleware - vendor = getattr(request.state, "vendor", None) - - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") - - logger.debug( - f"[SHOP_API] download_order_invoice: order {order_id}", - extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, - "customer_id": customer.id, - "order_id": order_id, - }, - ) - - # Get order - order = order_service.get_order(db=db, vendor_id=vendor.id, order_id=order_id) - - # Verify order belongs to customer - if order.customer_id != customer.id: - raise OrderNotFoundException(str(order_id)) - - # Only allow invoice download for orders that are at least processing - allowed_statuses = ["processing", "partially_shipped", "shipped", "delivered", "completed"] - if order.status not in allowed_statuses: - from app.exceptions import ValidationException - raise ValidationException("Invoice not available for pending orders") - - # Check if invoice exists for this order (via service layer) - invoice = invoice_service.get_invoice_by_order_id( - db=db, vendor_id=vendor.id, order_id=order_id - ) - - # Create invoice if it doesn't exist - if not invoice: - logger.info(f"Creating invoice for order {order_id} (customer download)") - invoice = invoice_service.create_invoice_from_order( - db=db, - vendor_id=vendor.id, - order_id=order_id, - ) - db.commit() - - # Get or generate PDF - pdf_path = invoice_service.get_pdf_path( - db=db, - vendor_id=vendor.id, - invoice_id=invoice.id, - ) - - if not pdf_path: - # Generate PDF - pdf_path = invoice_service.generate_pdf( - db=db, - vendor_id=vendor.id, - invoice_id=invoice.id, - ) - - # Verify file exists - if not FilePath(pdf_path).exists(): - raise InvoicePDFNotFoundException(invoice.id) - - filename = f"invoice-{invoice.invoice_number}.pdf" - - logger.info( - f"Customer {customer.id} downloading invoice {invoice.invoice_number} for order {order.order_number}" - ) - - return FileResponse( - path=pdf_path, - media_type="application/pdf", - filename=filename, - headers={ - "Content-Disposition": f'attachment; filename="{filename}"' - }, - ) diff --git a/app/api/v1/storefront/products.py b/app/api/v1/storefront/products.py deleted file mode 100644 index bc95f1f2..00000000 --- a/app/api/v1/storefront/products.py +++ /dev/null @@ -1,171 +0,0 @@ -# app/api/v1/shop/products.py -""" -Shop Product Catalog API (Public) - -Public endpoints for browsing product catalog in shop frontend. -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.services.product_service import product_service -from middleware.vendor_context import require_vendor_context -from models.database.vendor import Vendor -from models.schema.product import ( - ProductDetailResponse, - ProductListResponse, - ProductResponse, -) - -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"[SHOP_API] 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 = product_service.get_vendor_products( - db=db, - vendor_id=vendor.id, - skip=skip, - limit=limit, - is_active=True, # Only show active products to customers - 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"[SHOP_API] get_product_details for product {product_id}", - extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, - "product_id": product_id, - }, - ) - - product = product_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"[SHOP_API] 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 = product_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, - ) diff --git a/app/api/v1/storefront/profile.py b/app/api/v1/storefront/profile.py deleted file mode 100644 index f9ed6604..00000000 --- a/app/api/v1/storefront/profile.py +++ /dev/null @@ -1,161 +0,0 @@ -# app/api/v1/shop/profile.py -""" -Shop Profile API (Customer authenticated) - -Endpoints for managing customer profile in shop frontend. -Requires customer authentication. -""" - -import logging - -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session - -from app.api.deps import get_current_customer_api -from app.core.database import get_db -from app.exceptions import ValidationException -from app.services.auth_service import AuthService -from app.services.customer_service import customer_service -from models.database.customer import Customer -from models.schema.customer import ( - CustomerPasswordChange, - CustomerResponse, - CustomerUpdate, -) - -# Auth service for password operations -auth_service = AuthService() - -router = APIRouter() -logger = logging.getLogger(__name__) - - -@router.get("/profile", response_model=CustomerResponse) # authenticated -def get_profile( - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Get current customer profile. - - Returns the authenticated customer's profile information. - """ - logger.debug( - f"[SHOP_API] get_profile for customer {customer.id}", - extra={ - "customer_id": customer.id, - "email": customer.email, - }, - ) - - return CustomerResponse.model_validate(customer) - - -@router.put("/profile", response_model=CustomerResponse) -def update_profile( - update_data: CustomerUpdate, - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Update current customer profile. - - Allows updating profile fields like name, phone, marketing consent, etc. - Email changes require the new email to be unique within the vendor. - - Request Body: - - email: New email address (optional) - - first_name: First name (optional) - - last_name: Last name (optional) - - phone: Phone number (optional) - - marketing_consent: Marketing consent (optional) - - preferred_language: Preferred language (optional) - """ - logger.debug( - f"[SHOP_API] update_profile for customer {customer.id}", - extra={ - "customer_id": customer.id, - "email": customer.email, - "update_fields": [k for k, v in update_data.model_dump().items() if v is not None], - }, - ) - - # If email is being changed, check uniqueness within vendor - if update_data.email and update_data.email != customer.email: - existing = customer_service.get_customer_by_email( - db, customer.vendor_id, update_data.email - ) - if existing and existing.id != customer.id: - raise ValidationException("Email already in use") - - # Update only provided fields - update_dict = update_data.model_dump(exclude_unset=True) - for field, value in update_dict.items(): - if value is not None: - setattr(customer, field, value) - - db.commit() - db.refresh(customer) - - logger.info( - f"Customer {customer.id} updated profile", - extra={ - "customer_id": customer.id, - "updated_fields": list(update_dict.keys()), - }, - ) - - return CustomerResponse.model_validate(customer) - - -@router.put("/profile/password", response_model=dict) -def change_password( - password_data: CustomerPasswordChange, - customer: Customer = Depends(get_current_customer_api), - db: Session = Depends(get_db), -): - """ - Change customer password. - - Requires current password verification and matching new password confirmation. - - Request Body: - - current_password: Current password - - new_password: New password (min 8 chars, must contain letter and digit) - - confirm_password: Confirmation of new password - """ - logger.debug( - f"[SHOP_API] change_password for customer {customer.id}", - extra={ - "customer_id": customer.id, - "email": customer.email, - }, - ) - - # Verify current password - if not auth_service.auth_manager.verify_password( - password_data.current_password, customer.hashed_password - ): - raise ValidationException("Current password is incorrect") - - # Verify passwords match - if password_data.new_password != password_data.confirm_password: - raise ValidationException("New passwords do not match") - - # Check new password is different - if password_data.new_password == password_data.current_password: - raise ValidationException("New password must be different from current password") - - # Update password - customer.hashed_password = auth_service.hash_password(password_data.new_password) - db.commit() - - logger.info( - f"Customer {customer.id} changed password", - extra={ - "customer_id": customer.id, - "email": customer.email, - }, - ) - - return {"message": "Password changed successfully"} diff --git a/app/services/cart_service.py b/app/services/cart_service.py index 04876909..a5b8868a 100644 --- a/app/services/cart_service.py +++ b/app/services/cart_service.py @@ -1,453 +1,13 @@ # app/services/cart_service.py """ -Shopping cart service. +LEGACY LOCATION - This file re-exports from the canonical module location. -This module provides: -- Session-based cart management -- Cart item operations (add, update, remove) -- Cart total calculations +Canonical location: app/modules/cart/services/ -All monetary calculations use integer cents internally for precision. -See docs/architecture/money-handling.md for details. +This file exists for backward compatibility. New code should import from: + from app.modules.cart.services import cart_service """ -import logging +from app.modules.cart.services.cart_service import cart_service, CartService -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 models.database.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() +__all__ = ["cart_service", "CartService"] diff --git a/docs/proposals/PLAN_storefront-module-restructure.md b/docs/proposals/PLAN_storefront-module-restructure.md index 37c0deb8..01d3df96 100644 --- a/docs/proposals/PLAN_storefront-module-restructure.md +++ b/docs/proposals/PLAN_storefront-module-restructure.md @@ -315,7 +315,7 @@ After migrated to `app/modules/cart/services/cart_service.py`. 3. **Phase 3** - Create new modules (cart, checkout, catalog) ✅ COMPLETE 4. **Phase 4** - Move routes to modules ✅ COMPLETE 5. **Phase 5** - Fix direct model imports ✅ COMPLETE -6. **Phase 6** - Delete legacy files +6. **Phase 6** - Delete legacy files ✅ COMPLETE 7. **Phase 7** - Update documentation --- diff --git a/models/database/cart.py b/models/database/cart.py index ea91bdb7..20ea0b16 100644 --- a/models/database/cart.py +++ b/models/database/cart.py @@ -1,78 +1,13 @@ # models/database/cart.py -"""Cart item database model. +""" +LEGACY LOCATION - This file re-exports from the canonical module location. -Money values are stored as integer cents (e.g., €105.91 = 10591). -See docs/architecture/money-handling.md for details. +Canonical location: app/modules/cart/models/ + +This file exists for backward compatibility. New code should import from: + from app.modules.cart.models import CartItem """ -from sqlalchemy import ( - Column, - ForeignKey, - Index, - Integer, - String, - UniqueConstraint, -) -from sqlalchemy.orm import relationship +from app.modules.cart.models.cart import CartItem -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"" - - # === 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) +__all__ = ["CartItem"] diff --git a/models/schema/cart.py b/models/schema/cart.py index d59ca68b..64d5b6d0 100644 --- a/models/schema/cart.py +++ b/models/schema/cart.py @@ -1,91 +1,27 @@ # models/schema/cart.py """ -Pydantic schemas for shopping cart operations. +LEGACY LOCATION - This file re-exports from the canonical module location. + +Canonical location: app/modules/cart/schemas/ + +This file exists for backward compatibility. New code should import from: + from app.modules.cart.schemas import CartResponse, AddToCartRequest """ -from pydantic import BaseModel, ConfigDict, Field +from app.modules.cart.schemas.cart import ( + AddToCartRequest, + UpdateCartItemRequest, + CartItemResponse, + CartResponse, + CartOperationResponse, + ClearCartResponse, +) -# ============================================================================ -# 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") +__all__ = [ + "AddToCartRequest", + "UpdateCartItemRequest", + "CartItemResponse", + "CartResponse", + "CartOperationResponse", + "ClearCartResponse", +]