refactor(cleanup): delete legacy storefront routes, convert cart to re-exports
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
|
||||||
@@ -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."
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"
|
|
||||||
@@ -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}"'
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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"}
|
|
||||||
@@ -1,453 +1,13 @@
|
|||||||
# app/services/cart_service.py
|
# app/services/cart_service.py
|
||||||
"""
|
"""
|
||||||
Shopping cart service.
|
LEGACY LOCATION - This file re-exports from the canonical module location.
|
||||||
|
|
||||||
This module provides:
|
Canonical location: app/modules/cart/services/
|
||||||
- Session-based cart management
|
|
||||||
- Cart item operations (add, update, remove)
|
|
||||||
- Cart total calculations
|
|
||||||
|
|
||||||
All monetary calculations use integer cents internally for precision.
|
This file exists for backward compatibility. New code should import from:
|
||||||
See docs/architecture/money-handling.md for details.
|
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_
|
__all__ = ["cart_service", "CartService"]
|
||||||
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()
|
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ After migrated to `app/modules/cart/services/cart_service.py`.
|
|||||||
3. **Phase 3** - Create new modules (cart, checkout, catalog) ✅ COMPLETE
|
3. **Phase 3** - Create new modules (cart, checkout, catalog) ✅ COMPLETE
|
||||||
4. **Phase 4** - Move routes to modules ✅ COMPLETE
|
4. **Phase 4** - Move routes to modules ✅ COMPLETE
|
||||||
5. **Phase 5** - Fix direct model imports ✅ 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
|
7. **Phase 7** - Update documentation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,78 +1,13 @@
|
|||||||
# models/database/cart.py
|
# 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).
|
Canonical location: app/modules/cart/models/
|
||||||
See docs/architecture/money-handling.md for details.
|
|
||||||
|
This file exists for backward compatibility. New code should import from:
|
||||||
|
from app.modules.cart.models import CartItem
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import (
|
from app.modules.cart.models.cart import CartItem
|
||||||
Column,
|
|
||||||
ForeignKey,
|
|
||||||
Index,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
UniqueConstraint,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.core.database import Base
|
__all__ = ["CartItem"]
|
||||||
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)
|
|
||||||
|
|||||||
@@ -1,91 +1,27 @@
|
|||||||
# models/schema/cart.py
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
# ============================================================================
|
__all__ = [
|
||||||
# Request Schemas
|
"AddToCartRequest",
|
||||||
# ============================================================================
|
"UpdateCartItemRequest",
|
||||||
|
"CartItemResponse",
|
||||||
|
"CartResponse",
|
||||||
class AddToCartRequest(BaseModel):
|
"CartOperationResponse",
|
||||||
"""Request model for adding items to cart."""
|
"ClearCartResponse",
|
||||||
|
]
|
||||||
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")
|
|
||||||
|
|||||||
Reference in New Issue
Block a user