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:
2026-01-02 19:16:35 +01:00
parent ea0218746f
commit b5b32fb351
15 changed files with 3940 additions and 17 deletions

View File

@@ -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"])

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