Some checks failed
Phase 1 of the loyalty production launch plan: config & security hardening, dropped-data fix, DB integrity guards, rate limiting, and constant-time auth compare. 362 tests pass. - 1.4 Persist customer birth_date (new column + migration). Enrollment form was collecting it but the value was silently dropped because create_customer_for_enrollment never received it. Backfills existing customers without overwriting. - 1.1 LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON validated at startup (file must exist and be readable; ~ expanded). Adds is_google_wallet_enabled and is_apple_wallet_enabled derived flags. Prod path documented as ~/apps/orion/google-wallet-sa.json. - 1.5 CHECK constraints on loyalty_cards (points_balance, stamp_count non-negative) and loyalty_programs (min_purchase, points_per_euro, welcome_bonus non-negative; stamps_target >= 1). Mirrored as CheckConstraint in models. Pre-flight scan showed zero violations. - 1.3 @rate_limit on store mutating endpoints: stamp 60/min, redeem/points-earn 30-60/min, void/adjust 20/min, pin unlock 10/min. - 1.2 Constant-time hmac.compare_digest for Apple Wallet auth token (pulled forward from Phase 9 — code is safe whenever Apple ships). See app/modules/loyalty/docs/production-launch-plan.md for the full launch plan and remaining phases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
351 lines
11 KiB
Python
351 lines
11 KiB
Python
# app/modules/customers/schemas/customer.py
|
|
"""
|
|
Pydantic schemas for customer-related operations.
|
|
|
|
Provides schemas for:
|
|
- Customer registration and authentication
|
|
- Customer profile management
|
|
- Customer addresses
|
|
- Admin customer management
|
|
"""
|
|
|
|
from datetime import date, 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)
|
|
birth_date: date | None = Field(
|
|
None, description="Date of birth (YYYY-MM-DD)"
|
|
)
|
|
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
|
|
|
|
@field_validator("birth_date")
|
|
@classmethod
|
|
def birth_date_sane(cls, v: date | None) -> date | None:
|
|
"""Birthday must be in the past and within a plausible age range."""
|
|
if v is None:
|
|
return v
|
|
today = date.today()
|
|
if v >= today:
|
|
raise ValueError("birth_date must be in the past")
|
|
# Plausible human age range — guards against typos like 0001-01-01
|
|
years = (today - v).days / 365.25
|
|
if years < 13 or years > 120:
|
|
raise ValueError("birth_date implies an implausible age")
|
|
return v
|
|
|
|
|
|
class CustomerPasswordChange(BaseModel):
|
|
"""Schema for customer password change."""
|
|
|
|
current_password: str = Field(..., description="Current password")
|
|
new_password: str = Field(
|
|
..., min_length=8, description="New password (minimum 8 characters)"
|
|
)
|
|
confirm_password: str = Field(..., description="Confirm new password")
|
|
|
|
@field_validator("new_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
|
|
|
|
|
|
# ============================================================================
|
|
# Customer Response
|
|
# ============================================================================
|
|
|
|
|
|
class CustomerResponse(BaseModel):
|
|
"""Schema for customer response (excludes password)."""
|
|
|
|
id: int
|
|
store_id: int
|
|
email: str
|
|
first_name: str | None
|
|
last_name: str | None
|
|
phone: str | None
|
|
birth_date: date | None = None
|
|
customer_number: str
|
|
marketing_consent: bool
|
|
preferred_language: str | None
|
|
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
|
|
store_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
|
|
|
|
|
|
# ============================================================================
|
|
# Store Customer Management Response Schemas
|
|
# ============================================================================
|
|
|
|
|
|
class CustomerMessageResponse(BaseModel):
|
|
"""Simple message response for customer operations."""
|
|
|
|
message: str
|
|
|
|
|
|
class StoreCustomerListResponse(BaseModel):
|
|
"""Schema for store 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 store management.
|
|
|
|
Note: Order-related statistics (total_orders, total_spent, last_order_date)
|
|
are available via the orders module endpoint:
|
|
GET /api/store/customers/{customer_id}/order-stats
|
|
"""
|
|
|
|
id: int | None = None
|
|
store_id: int | None = None
|
|
email: str | None = None
|
|
first_name: str | None = None
|
|
last_name: str | None = None
|
|
phone: str | None = None
|
|
birth_date: date | None = None
|
|
customer_number: str | None = None
|
|
marketing_consent: bool | None = None
|
|
preferred_language: str | 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
|
|
|
|
|
|
# ============================================================================
|
|
# Admin Customer Management Response Schemas
|
|
# ============================================================================
|
|
|
|
|
|
class AdminCustomerItem(BaseModel):
|
|
"""Admin customer list item with store info."""
|
|
|
|
id: int
|
|
store_id: int
|
|
email: str
|
|
first_name: str | None = None
|
|
last_name: str | None = None
|
|
phone: str | None = None
|
|
birth_date: date | None = None
|
|
customer_number: str
|
|
marketing_consent: bool = False
|
|
preferred_language: str | None = None
|
|
is_active: bool = True
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
store_name: str | None = None
|
|
store_code: str | None = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class AdminCustomerListResponse(BaseModel):
|
|
"""Admin paginated customer list with skip/limit."""
|
|
|
|
customers: list[AdminCustomerItem] = []
|
|
total: int = 0
|
|
skip: int = 0
|
|
limit: int = 20
|
|
|
|
|
|
class AdminCustomerDetailResponse(AdminCustomerItem):
|
|
"""Detailed customer response for admin."""
|