Files
orion/models/schema/customer.py
Samir Boulahtit b5b32fb351 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>
2026-01-02 19:16:35 +01:00

312 lines
9.2 KiB
Python

# models/schema/customer.py
"""
Pydantic schema for customer-related operations.
"""
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, EmailStr, Field, field_validator
# ============================================================================
# Customer Registration & Authentication
# ============================================================================
class CustomerRegister(BaseModel):
"""Schema for customer registration."""
email: EmailStr = Field(..., description="Customer email address")
password: str = Field(
..., min_length=8, description="Password (minimum 8 characters)"
)
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
phone: str | None = Field(None, max_length=50)
marketing_consent: bool = Field(default=False)
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("email")
@classmethod
def email_lowercase(cls, v: str) -> str:
"""Convert email to lowercase."""
return v.lower()
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
"""Validate password strength."""
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(char.isdigit() for char in v):
raise ValueError("Password must contain at least one digit")
if not any(char.isalpha() for char in v):
raise ValueError("Password must contain at least one letter")
return v
class CustomerUpdate(BaseModel):
"""Schema for updating customer profile."""
email: EmailStr | None = None
first_name: str | None = Field(None, min_length=1, max_length=100)
last_name: str | None = Field(None, min_length=1, max_length=100)
phone: str | None = Field(None, max_length=50)
marketing_consent: bool | None = None
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("email")
@classmethod
def email_lowercase(cls, v: str | None) -> str | None:
"""Convert email to lowercase."""
return v.lower() if v else None
# ============================================================================
# Customer Response
# ============================================================================
class CustomerResponse(BaseModel):
"""Schema for customer response (excludes password)."""
id: int
vendor_id: int
email: str
first_name: str | None
last_name: str | None
phone: str | None
customer_number: str
marketing_consent: bool
preferred_language: str | None
last_order_date: datetime | None
total_orders: int
total_spent: Decimal
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
@property
def full_name(self) -> str:
"""Get customer full name."""
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.email
class CustomerListResponse(BaseModel):
"""Schema for paginated customer list."""
customers: list[CustomerResponse]
total: int
page: int
per_page: int
total_pages: int
# ============================================================================
# Customer Address
# ============================================================================
class CustomerAddressCreate(BaseModel):
"""Schema for creating customer address."""
address_type: str = Field(..., pattern="^(billing|shipping)$")
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
company: str | None = Field(None, max_length=200)
address_line_1: str = Field(..., min_length=1, max_length=255)
address_line_2: str | None = Field(None, max_length=255)
city: str = Field(..., min_length=1, max_length=100)
postal_code: str = Field(..., min_length=1, max_length=20)
country_name: str = Field(..., min_length=2, max_length=100)
country_iso: str = Field(..., min_length=2, max_length=5)
is_default: bool = Field(default=False)
class CustomerAddressUpdate(BaseModel):
"""Schema for updating customer address."""
address_type: str | None = Field(None, pattern="^(billing|shipping)$")
first_name: str | None = Field(None, min_length=1, max_length=100)
last_name: str | None = Field(None, min_length=1, max_length=100)
company: str | None = Field(None, max_length=200)
address_line_1: str | None = Field(None, min_length=1, max_length=255)
address_line_2: str | None = Field(None, max_length=255)
city: str | None = Field(None, min_length=1, max_length=100)
postal_code: str | None = Field(None, min_length=1, max_length=20)
country_name: str | None = Field(None, min_length=2, max_length=100)
country_iso: str | None = Field(None, min_length=2, max_length=5)
is_default: bool | None = None
class CustomerAddressResponse(BaseModel):
"""Schema for customer address response."""
id: int
vendor_id: int
customer_id: int
address_type: str
first_name: str
last_name: str
company: str | None
address_line_1: str
address_line_2: str | None
city: str
postal_code: str
country_name: str
country_iso: str
is_default: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class CustomerAddressListResponse(BaseModel):
"""Schema for customer address list response."""
addresses: list[CustomerAddressResponse]
total: int
# ============================================================================
# Customer Preferences
# ============================================================================
class CustomerPreferencesUpdate(BaseModel):
"""Schema for updating customer preferences."""
marketing_consent: bool | None = None
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
currency: str | None = Field(None, max_length=3)
notification_preferences: dict[str, bool] | None = None
# ============================================================================
# Vendor Customer Management Response Schemas
# ============================================================================
class CustomerMessageResponse(BaseModel):
"""Simple message response for customer operations."""
message: str
class VendorCustomerListResponse(BaseModel):
"""Schema for vendor customer list with skip/limit pagination."""
customers: list[CustomerResponse] = []
total: int = 0
skip: int = 0
limit: int = 100
message: str | None = None
class CustomerDetailResponse(BaseModel):
"""Detailed customer response for vendor management."""
id: int | None = None
vendor_id: int | None = None
email: str | None = None
first_name: str | None = None
last_name: str | None = None
phone: str | None = None
customer_number: str | None = None
marketing_consent: bool | None = None
preferred_language: str | None = None
last_order_date: datetime | None = None
total_orders: int | None = None
total_spent: Decimal | None = None
is_active: bool | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
message: str | None = None
model_config = {"from_attributes": True}
class CustomerOrderInfo(BaseModel):
"""Basic order info for customer order history."""
id: int
order_number: str
status: str
total: Decimal
created_at: datetime
class CustomerOrdersResponse(BaseModel):
"""Response for customer order history."""
orders: list[CustomerOrderInfo] = []
total: int = 0
message: str | None = None
class CustomerStatisticsResponse(BaseModel):
"""Response for customer statistics."""
total: int = 0
active: int = 0
inactive: int = 0
with_orders: int = 0
total_spent: float = 0.0
total_orders: int = 0
avg_order_value: float = 0.0
# ============================================================================
# Admin Customer Management Response Schemas
# ============================================================================
class AdminCustomerItem(BaseModel):
"""Admin customer list item with vendor info."""
id: int
vendor_id: int
email: str
first_name: str | None = None
last_name: str | None = None
phone: str | None = None
customer_number: str
marketing_consent: bool = False
preferred_language: str | None = None
last_order_date: datetime | None = None
total_orders: int = 0
total_spent: float = 0.0
is_active: bool = True
created_at: datetime
updated_at: datetime
vendor_name: str | None = None
vendor_code: str | None = None
model_config = {"from_attributes": True}
class CustomerListResponse(BaseModel):
"""Admin paginated customer list with skip/limit."""
customers: list[AdminCustomerItem] = []
total: int = 0
skip: int = 0
limit: int = 20
class CustomerDetailResponse(AdminCustomerItem):
"""Detailed customer response for admin."""
pass