feat: add customer multiple addresses management
- Add CustomerAddressService with CRUD operations - Add shop API endpoints for address management (GET, POST, PUT, DELETE) - Add set default endpoint for address type - Implement addresses.html with full UI (cards, modals, Alpine.js) - Integrate saved addresses in checkout flow - Address selector dropdowns for shipping/billing - Auto-select default addresses - Save new address checkbox option - Add country_iso field alongside country_name - Add address exceptions (NotFound, LimitExceeded, InvalidType) - Max 10 addresses per customer limit - One default address per type (shipping/billing) - Add unit tests for CustomerAddressService - Add integration tests for shop addresses API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ Authentication:
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Import shop routers
|
||||
from . import auth, carts, content_pages, messages, orders, products
|
||||
from . import addresses, auth, carts, content_pages, messages, orders, products
|
||||
|
||||
# Create shop router
|
||||
router = APIRouter()
|
||||
@@ -30,6 +30,9 @@ router = APIRouter()
|
||||
# SHOP API ROUTES (All vendor-context aware via middleware)
|
||||
# ============================================================================
|
||||
|
||||
# Addresses (authenticated)
|
||||
router.include_router(addresses.router, tags=["shop-addresses"])
|
||||
|
||||
# Authentication (public)
|
||||
router.include_router(auth.router, tags=["shop-auth"])
|
||||
|
||||
|
||||
269
app/api/v1/shop/addresses.py
Normal file
269
app/api/v1/shop/addresses.py
Normal file
@@ -0,0 +1,269 @@
|
||||
# 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)
|
||||
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)
|
||||
Reference in New Issue
Block a user