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:
314
app/services/customer_address_service.py
Normal file
314
app/services/customer_address_service.py
Normal file
@@ -0,0 +1,314 @@
|
||||
# app/services/customer_address_service.py
|
||||
"""
|
||||
Customer Address Service
|
||||
|
||||
Business logic for managing customer addresses with vendor isolation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
AddressLimitExceededException,
|
||||
AddressNotFoundException,
|
||||
)
|
||||
from models.database.customer import CustomerAddress
|
||||
from models.schema.customer import CustomerAddressCreate, CustomerAddressUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomerAddressService:
|
||||
"""Service for managing customer addresses with vendor isolation."""
|
||||
|
||||
MAX_ADDRESSES_PER_CUSTOMER = 10
|
||||
|
||||
def list_addresses(
|
||||
self, db: Session, vendor_id: int, customer_id: int
|
||||
) -> list[CustomerAddress]:
|
||||
"""
|
||||
Get all addresses for a customer.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
|
||||
Returns:
|
||||
List of customer addresses
|
||||
"""
|
||||
return (
|
||||
db.query(CustomerAddress)
|
||||
.filter(
|
||||
CustomerAddress.vendor_id == vendor_id,
|
||||
CustomerAddress.customer_id == customer_id,
|
||||
)
|
||||
.order_by(CustomerAddress.is_default.desc(), CustomerAddress.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_address(
|
||||
self, db: Session, vendor_id: int, customer_id: int, address_id: int
|
||||
) -> CustomerAddress:
|
||||
"""
|
||||
Get a specific address with ownership validation.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_id: Address ID
|
||||
|
||||
Returns:
|
||||
Customer address
|
||||
|
||||
Raises:
|
||||
AddressNotFoundException: If address not found or doesn't belong to customer
|
||||
"""
|
||||
address = (
|
||||
db.query(CustomerAddress)
|
||||
.filter(
|
||||
CustomerAddress.id == address_id,
|
||||
CustomerAddress.vendor_id == vendor_id,
|
||||
CustomerAddress.customer_id == customer_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not address:
|
||||
raise AddressNotFoundException(address_id)
|
||||
|
||||
return address
|
||||
|
||||
def get_default_address(
|
||||
self, db: Session, vendor_id: int, customer_id: int, address_type: str
|
||||
) -> CustomerAddress | None:
|
||||
"""
|
||||
Get the default address for a specific type.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_type: 'shipping' or 'billing'
|
||||
|
||||
Returns:
|
||||
Default address or None if not set
|
||||
"""
|
||||
return (
|
||||
db.query(CustomerAddress)
|
||||
.filter(
|
||||
CustomerAddress.vendor_id == vendor_id,
|
||||
CustomerAddress.customer_id == customer_id,
|
||||
CustomerAddress.address_type == address_type,
|
||||
CustomerAddress.is_default == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def create_address(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
customer_id: int,
|
||||
address_data: CustomerAddressCreate,
|
||||
) -> CustomerAddress:
|
||||
"""
|
||||
Create a new address for a customer.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_data: Address creation data
|
||||
|
||||
Returns:
|
||||
Created customer address
|
||||
|
||||
Raises:
|
||||
AddressLimitExceededException: If max addresses reached
|
||||
"""
|
||||
# Check address limit
|
||||
current_count = (
|
||||
db.query(CustomerAddress)
|
||||
.filter(
|
||||
CustomerAddress.vendor_id == vendor_id,
|
||||
CustomerAddress.customer_id == customer_id,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
if current_count >= self.MAX_ADDRESSES_PER_CUSTOMER:
|
||||
raise AddressLimitExceededException(self.MAX_ADDRESSES_PER_CUSTOMER)
|
||||
|
||||
# If setting as default, clear other defaults of same type
|
||||
if address_data.is_default:
|
||||
self._clear_other_defaults(
|
||||
db, vendor_id, customer_id, address_data.address_type
|
||||
)
|
||||
|
||||
# Create the address
|
||||
address = CustomerAddress(
|
||||
vendor_id=vendor_id,
|
||||
customer_id=customer_id,
|
||||
address_type=address_data.address_type,
|
||||
first_name=address_data.first_name,
|
||||
last_name=address_data.last_name,
|
||||
company=address_data.company,
|
||||
address_line_1=address_data.address_line_1,
|
||||
address_line_2=address_data.address_line_2,
|
||||
city=address_data.city,
|
||||
postal_code=address_data.postal_code,
|
||||
country_name=address_data.country_name,
|
||||
country_iso=address_data.country_iso,
|
||||
is_default=address_data.is_default,
|
||||
)
|
||||
|
||||
db.add(address)
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Created address {address.id} for customer {customer_id} "
|
||||
f"(type={address_data.address_type}, default={address_data.is_default})"
|
||||
)
|
||||
|
||||
return address
|
||||
|
||||
def update_address(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
customer_id: int,
|
||||
address_id: int,
|
||||
address_data: CustomerAddressUpdate,
|
||||
) -> CustomerAddress:
|
||||
"""
|
||||
Update an existing address.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_id: Address ID
|
||||
address_data: Address update data
|
||||
|
||||
Returns:
|
||||
Updated customer address
|
||||
|
||||
Raises:
|
||||
AddressNotFoundException: If address not found
|
||||
"""
|
||||
address = self.get_address(db, vendor_id, customer_id, address_id)
|
||||
|
||||
# Update only provided fields
|
||||
update_data = address_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle default flag - clear others if setting to default
|
||||
if update_data.get("is_default") is True:
|
||||
# Use updated type if provided, otherwise current type
|
||||
address_type = update_data.get("address_type", address.address_type)
|
||||
self._clear_other_defaults(
|
||||
db, vendor_id, customer_id, address_type, exclude_id=address_id
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(address, field, value)
|
||||
|
||||
db.flush()
|
||||
|
||||
logger.info(f"Updated address {address_id} for customer {customer_id}")
|
||||
|
||||
return address
|
||||
|
||||
def delete_address(
|
||||
self, db: Session, vendor_id: int, customer_id: int, address_id: int
|
||||
) -> None:
|
||||
"""
|
||||
Delete an address.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_id: Address ID
|
||||
|
||||
Raises:
|
||||
AddressNotFoundException: If address not found
|
||||
"""
|
||||
address = self.get_address(db, vendor_id, customer_id, address_id)
|
||||
|
||||
db.delete(address)
|
||||
db.flush()
|
||||
|
||||
logger.info(f"Deleted address {address_id} for customer {customer_id}")
|
||||
|
||||
def set_default(
|
||||
self, db: Session, vendor_id: int, customer_id: int, address_id: int
|
||||
) -> CustomerAddress:
|
||||
"""
|
||||
Set an address as the default for its type.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_id: Address ID
|
||||
|
||||
Returns:
|
||||
Updated customer address
|
||||
|
||||
Raises:
|
||||
AddressNotFoundException: If address not found
|
||||
"""
|
||||
address = self.get_address(db, vendor_id, customer_id, address_id)
|
||||
|
||||
# Clear other defaults of same type
|
||||
self._clear_other_defaults(
|
||||
db, vendor_id, customer_id, address.address_type, exclude_id=address_id
|
||||
)
|
||||
|
||||
# Set this one as default
|
||||
address.is_default = True
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Set address {address_id} as default {address.address_type} "
|
||||
f"for customer {customer_id}"
|
||||
)
|
||||
|
||||
return address
|
||||
|
||||
def _clear_other_defaults(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
customer_id: int,
|
||||
address_type: str,
|
||||
exclude_id: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Clear the default flag on other addresses of the same type.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_type: 'shipping' or 'billing'
|
||||
exclude_id: Address ID to exclude from clearing
|
||||
"""
|
||||
query = db.query(CustomerAddress).filter(
|
||||
CustomerAddress.vendor_id == vendor_id,
|
||||
CustomerAddress.customer_id == customer_id,
|
||||
CustomerAddress.address_type == address_type,
|
||||
CustomerAddress.is_default == True, # noqa: E712
|
||||
)
|
||||
|
||||
if exclude_id:
|
||||
query = query.filter(CustomerAddress.id != exclude_id)
|
||||
|
||||
query.update({"is_default": False}, synchronize_session=False)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
customer_address_service = CustomerAddressService()
|
||||
Reference in New Issue
Block a user