Renamed schemas to schema as per naming conventions

This commit is contained in:
2025-10-11 12:14:11 +02:00
parent 199be1f1b9
commit 1e2f211057
49 changed files with 285 additions and 3701 deletions

23
models/schema/__init__.py Normal file
View File

@@ -0,0 +1,23 @@
# models/schema/__init__.py
"""API models package - Pydantic models for request/response validation."""
from . import auth
# Import API model modules
from . import base
from . import marketplace_import_job
from . import marketplace_product
from . import stats
from . import inventory
from . import vendor
# Common imports for convenience
from .base import * # Base Pydantic models
__all__ = [
"base",
"auth",
"marketplace_product",
"inventory.py",
"vendor",
"marketplace_import_job",
"stats",
]

57
models/schema/auth.py Normal file
View File

@@ -0,0 +1,57 @@
# auth.py - Keep security-critical validation
import re
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
class UserRegister(BaseModel):
email: EmailStr = Field(..., description="Valid email address")
username: str = Field(..., description="Username")
password: str = Field(..., description="Password")
# Keep security validation in Pydantic for auth
@field_validator("username")
@classmethod
def validate_username(cls, v):
if not re.match(r"^[a-zA-Z0-9_]+$", v):
raise ValueError("Username must contain only letters, numbers, or underscores")
return v.lower().strip()
@field_validator("password")
@classmethod
def validate_password(cls, v):
if len(v) < 6:
raise ValueError("Password must be at least 6 characters long")
return v
class UserLogin(BaseModel):
username: str = Field(..., description="Username")
password: str = Field(..., description="Password")
@field_validator("username")
@classmethod
def validate_username(cls, v):
return v.strip()
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
username: str
role: str
is_active: bool
last_login: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
user: UserResponse

21
models/schema/base.py Normal file
View File

@@ -0,0 +1,21 @@
from typing import Generic, List, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class ListResponse(BaseModel, Generic[T]):
"""Generic list response model"""
items: List[T]
total: int
skip: int
limit: int
class StatusResponse(BaseModel):
"""Generic status response"""
success: bool
message: str

193
models/schema/customer.py Normal file
View File

@@ -0,0 +1,193 @@
# models/schema/customer.py
"""
Pydantic schema for customer-related operations.
"""
from datetime import datetime
from decimal import Decimal
from typing import Optional, Dict, Any, List
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: Optional[str] = Field(None, max_length=50)
marketing_consent: bool = Field(default=False)
@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: Optional[EmailStr] = None
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
last_name: Optional[str] = Field(None, min_length=1, max_length=100)
phone: Optional[str] = Field(None, max_length=50)
marketing_consent: Optional[bool] = None
@field_validator('email')
@classmethod
def email_lowercase(cls, v: Optional[str]) -> Optional[str]:
"""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: Optional[str]
last_name: Optional[str]
phone: Optional[str]
customer_number: str
marketing_consent: bool
last_order_date: Optional[datetime]
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: Optional[str] = Field(None, max_length=200)
address_line_1: str = Field(..., min_length=1, max_length=255)
address_line_2: Optional[str] = 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: str = Field(..., min_length=2, max_length=100)
is_default: bool = Field(default=False)
class CustomerAddressUpdate(BaseModel):
"""Schema for updating customer address."""
address_type: Optional[str] = Field(None, pattern="^(billing|shipping)$")
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
last_name: Optional[str] = Field(None, min_length=1, max_length=100)
company: Optional[str] = Field(None, max_length=200)
address_line_1: Optional[str] = Field(None, min_length=1, max_length=255)
address_line_2: Optional[str] = Field(None, max_length=255)
city: Optional[str] = Field(None, min_length=1, max_length=100)
postal_code: Optional[str] = Field(None, min_length=1, max_length=20)
country: Optional[str] = Field(None, min_length=2, max_length=100)
is_default: Optional[bool] = 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: Optional[str]
address_line_1: str
address_line_2: Optional[str]
city: str
postal_code: str
country: str
is_default: bool
created_at: datetime
updated_at: datetime
model_config = {
"from_attributes": True
}
# ============================================================================
# Customer Statistics
# ============================================================================
class CustomerStatsResponse(BaseModel):
"""Schema for customer statistics."""
customer_id: int
total_orders: int
total_spent: Decimal
average_order_value: Decimal
last_order_date: Optional[datetime]
first_order_date: Optional[datetime]
lifetime_value: Decimal
# ============================================================================
# Customer Preferences
# ============================================================================
class CustomerPreferencesUpdate(BaseModel):
"""Schema for updating customer preferences."""
marketing_consent: Optional[bool] = None
language: Optional[str] = Field(None, max_length=10)
currency: Optional[str] = Field(None, max_length=3)
notification_preferences: Optional[Dict[str, bool]] = None

View File

@@ -0,0 +1,77 @@
# models/schema/inventory.py
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
class InventoryBase(BaseModel):
product_id: int = Field(..., description="Product ID in vendor catalog")
location: str = Field(..., description="Storage location")
class InventoryCreate(InventoryBase):
"""Set exact inventory quantity (replaces existing)."""
quantity: int = Field(..., description="Exact inventory quantity", ge=0)
class InventoryAdjust(InventoryBase):
"""Add or remove inventory quantity."""
quantity: int = Field(..., description="Quantity to add (positive) or remove (negative)")
class InventoryUpdate(BaseModel):
"""Update inventory fields."""
quantity: Optional[int] = Field(None, ge=0)
reserved_quantity: Optional[int] = Field(None, ge=0)
location: Optional[str] = None
class InventoryReserve(BaseModel):
"""Reserve inventory for orders."""
product_id: int
location: str
quantity: int = Field(..., gt=0)
class InventoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int
vendor_id: int
location: str
quantity: int
reserved_quantity: int
gtin: Optional[str]
created_at: datetime
updated_at: datetime
@property
def available_quantity(self):
return max(0, self.quantity - self.reserved_quantity)
class InventoryLocationResponse(BaseModel):
location: str
quantity: int
reserved_quantity: int
available_quantity: int
class ProductInventorySummary(BaseModel):
"""Inventory summary for a product."""
product_id: int
vendor_id: int
product_sku: Optional[str]
product_title: str
total_quantity: int
total_reserved: int
total_available: int
locations: List[InventoryLocationResponse]
class InventoryListResponse(BaseModel):
inventories: List[InventoryResponse]
total: int
skip: int
limit: int

View File

@@ -0,0 +1,71 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
class MarketplaceImportJobRequest(BaseModel):
"""Request schema for triggering marketplace import.
Note: vendor_id is injected by middleware, not from request body.
"""
source_url: str = Field(..., description="URL to CSV file from marketplace")
marketplace: str = Field(default="Letzshop", description="Marketplace name")
batch_size: Optional[int] = Field(1000, description="Processing batch size", ge=100, le=10000)
@field_validator("source_url")
@classmethod
def validate_url(cls, v):
# Basic URL security validation
if not v.startswith(("http://", "https://")):
raise ValueError("URL must start with http:// or https://")
return v.strip()
@field_validator("marketplace")
@classmethod
def validate_marketplace(cls, v):
return v.strip()
class MarketplaceImportJobResponse(BaseModel):
"""Response schema for marketplace import job."""
model_config = ConfigDict(from_attributes=True)
job_id: int
vendor_id: int
vendor_code: str # Populated from vendor relationship
vendor_name: str # Populated from vendor relationship
marketplace: str
source_url: str
status: str
# Counts
imported: int = 0
updated: int = 0
total_processed: int = 0
error_count: int = 0
# Error details
error_message: Optional[str] = None
# Timestamps
created_at: datetime
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
class MarketplaceImportJobListResponse(BaseModel):
"""Response schema for list of import jobs."""
jobs: list[MarketplaceImportJobResponse]
total: int
skip: int
limit: int
class MarketplaceImportJobStatusUpdate(BaseModel):
"""Schema for updating import job status (internal use)."""
status: str
imported_count: Optional[int] = None
updated_count: Optional[int] = None
error_count: Optional[int] = None
total_processed: Optional[int] = None
error_message: Optional[str] = None

View File

@@ -0,0 +1,71 @@
# models/schema/marketplace_products.py - Simplified validation
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
from models.schema.inventory import ProductInventorySummary
class MarketplaceProductBase(BaseModel):
marketplace_product_id: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
link: Optional[str] = None
image_link: Optional[str] = None
availability: Optional[str] = None
price: Optional[str] = None
brand: Optional[str] = None
gtin: Optional[str] = None
mpn: Optional[str] = None
condition: Optional[str] = None
adult: Optional[str] = None
multipack: Optional[int] = None
is_bundle: Optional[str] = None
age_group: Optional[str] = None
color: Optional[str] = None
gender: Optional[str] = None
material: Optional[str] = None
pattern: Optional[str] = None
size: Optional[str] = None
size_type: Optional[str] = None
size_system: Optional[str] = None
item_group_id: Optional[str] = None
google_product_category: Optional[str] = None
product_type: Optional[str] = None
custom_label_0: Optional[str] = None
custom_label_1: Optional[str] = None
custom_label_2: Optional[str] = None
custom_label_3: Optional[str] = None
custom_label_4: Optional[str] = None
additional_image_link: Optional[str] = None
sale_price: Optional[str] = None
unit_pricing_measure: Optional[str] = None
unit_pricing_base_measure: Optional[str] = None
identifier_exists: Optional[str] = None
shipping: Optional[str] = None
currency: Optional[str] = None
marketplace: Optional[str] = None
vendor_name: Optional[str] = None
class MarketplaceProductCreate(MarketplaceProductBase):
marketplace_product_id: str = Field(..., description="MarketplaceProduct identifier")
title: str = Field(..., description="MarketplaceProduct title")
# Removed: min_length constraints and custom validators
# Service will handle empty string validation with proper domain exceptions
class MarketplaceProductUpdate(MarketplaceProductBase):
pass
class MarketplaceProductResponse(MarketplaceProductBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
updated_at: datetime
class MarketplaceProductListResponse(BaseModel):
products: List[MarketplaceProductResponse]
total: int
skip: int
limit: int
class MarketplaceProductDetailResponse(BaseModel):
product: MarketplaceProductResponse
inventory_info: Optional[ProductInventorySummary] = None

170
models/schema/order.py Normal file
View File

@@ -0,0 +1,170 @@
# models/schema/order.py
"""
Pydantic schema for order operations.
"""
from datetime import datetime
from typing import List, Optional
from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict
# ============================================================================
# Order Item Schemas
# ============================================================================
class OrderItemCreate(BaseModel):
"""Schema for creating an order item."""
product_id: int
quantity: int = Field(..., ge=1)
class OrderItemResponse(BaseModel):
"""Schema for order item response."""
model_config = ConfigDict(from_attributes=True)
id: int
order_id: int
product_id: int
product_name: str
product_sku: Optional[str]
quantity: int
unit_price: float
total_price: float
inventory_reserved: bool
inventory_fulfilled: bool
created_at: datetime
updated_at: datetime
# ============================================================================
# Order Address Schemas
# ============================================================================
class OrderAddressCreate(BaseModel):
"""Schema for order address (shipping/billing)."""
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
company: Optional[str] = Field(None, max_length=200)
address_line_1: str = Field(..., min_length=1, max_length=255)
address_line_2: Optional[str] = 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: str = Field(..., min_length=2, max_length=100)
class OrderAddressResponse(BaseModel):
"""Schema for order address response."""
model_config = ConfigDict(from_attributes=True)
id: int
address_type: str
first_name: str
last_name: str
company: Optional[str]
address_line_1: str
address_line_2: Optional[str]
city: str
postal_code: str
country: str
# ============================================================================
# Order Create/Update Schemas
# ============================================================================
class OrderCreate(BaseModel):
"""Schema for creating an order."""
customer_id: Optional[int] = None # Optional for guest checkout
items: List[OrderItemCreate] = Field(..., min_length=1)
# Addresses
shipping_address: OrderAddressCreate
billing_address: Optional[OrderAddressCreate] = None # Use shipping if not provided
# Optional fields
shipping_method: Optional[str] = None
customer_notes: Optional[str] = Field(None, max_length=1000)
# Cart/session info
session_id: Optional[str] = None
class OrderUpdate(BaseModel):
"""Schema for updating order status."""
status: Optional[str] = Field(
None,
pattern="^(pending|processing|shipped|delivered|cancelled|refunded)$"
)
tracking_number: Optional[str] = None
internal_notes: Optional[str] = None
# ============================================================================
# Order Response Schemas
# ============================================================================
class OrderResponse(BaseModel):
"""Schema for order response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
customer_id: int
order_number: str
status: str
# Financial
subtotal: float
tax_amount: float
shipping_amount: float
discount_amount: float
total_amount: float
currency: str
# Shipping
shipping_method: Optional[str]
tracking_number: Optional[str]
# Notes
customer_notes: Optional[str]
internal_notes: Optional[str]
# Timestamps
created_at: datetime
updated_at: datetime
paid_at: Optional[datetime]
shipped_at: Optional[datetime]
delivered_at: Optional[datetime]
cancelled_at: Optional[datetime]
class OrderDetailResponse(OrderResponse):
"""Schema for detailed order response with items and addresses."""
items: List[OrderItemResponse]
shipping_address: OrderAddressResponse
billing_address: OrderAddressResponse
class OrderListResponse(BaseModel):
"""Schema for paginated order list."""
orders: List[OrderResponse]
total: int
skip: int
limit: int
# ============================================================================
# Order Statistics
# ============================================================================
class OrderStatsResponse(BaseModel):
"""Schema for order statistics."""
total_orders: int
pending_orders: int
processing_orders: int
shipped_orders: int
delivered_orders: int
cancelled_orders: int
total_revenue: Decimal
average_order_value: Decimal

69
models/schema/product.py Normal file
View File

@@ -0,0 +1,69 @@
# models/schema/product.py
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
from models.schema.marketplace_product import MarketplaceProductResponse
from models.schema.inventory import InventoryLocationResponse
class ProductCreate(BaseModel):
marketplace_product_id: int = Field(..., description="MarketplaceProduct ID to add to vendor catalog")
product_id: Optional[str] = Field(None, description="Vendor's internal SKU/product ID")
price: Optional[float] = Field(None, ge=0)
sale_price: Optional[float] = Field(None, ge=0)
currency: Optional[str] = None
availability: Optional[str] = None
condition: Optional[str] = None
is_featured: bool = False
min_quantity: int = Field(1, ge=1)
max_quantity: Optional[int] = Field(None, ge=1)
class ProductUpdate(BaseModel):
product_id: Optional[str] = None
price: Optional[float] = Field(None, ge=0)
sale_price: Optional[float] = Field(None, ge=0)
currency: Optional[str] = None
availability: Optional[str] = None
condition: Optional[str] = None
is_featured: Optional[bool] = None
is_active: Optional[bool] = None
min_quantity: Optional[int] = Field(None, ge=1)
max_quantity: Optional[int] = Field(None, ge=1)
class ProductResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
marketplace_product: MarketplaceProductResponse
product_id: Optional[str]
price: Optional[float]
sale_price: Optional[float]
currency: Optional[str]
availability: Optional[str]
condition: Optional[str]
is_featured: bool
is_active: bool
display_order: int
min_quantity: int
max_quantity: Optional[int]
created_at: datetime
updated_at: datetime
# Include inventory summary
total_inventory: Optional[int] = None
available_inventory: Optional[int] = None
class ProductDetailResponse(ProductResponse):
"""Product with full inventory details."""
inventory_locations: List[InventoryLocationResponse] = []
class ProductListResponse(BaseModel):
products: List[ProductResponse]
total: int
skip: int
limit: int

22
models/schema/stats.py Normal file
View File

@@ -0,0 +1,22 @@
import re
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
class StatsResponse(BaseModel):
total_products: int
unique_brands: int
unique_categories: int
unique_marketplaces: int = 0
unique_vendors: int = 0
total_inventory_entries: int = 0
total_inventory_quantity: int = 0
class MarketplaceStatsResponse(BaseModel):
marketplace: str
total_products: int
unique_vendors: int
unique_brands: int

140
models/schema/vendor.py Normal file
View File

@@ -0,0 +1,140 @@
# models/schema/vendor.py
import re
from datetime import datetime
from typing import Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
class VendorCreate(BaseModel):
"""Schema for creating a new vendor."""
vendor_code: str = Field(..., description="Unique vendor identifier (e.g., TECHSTORE)")
subdomain: str = Field(..., description="Unique subdomain for the vendor")
name: str = Field(..., description="Display name of the vendor")
description: Optional[str] = None
# Owner information - REQUIRED for admin creation
owner_email: str = Field(..., description="Email for the vendor owner account")
# Contact information
contact_phone: Optional[str] = None
website: Optional[str] = None
# Business information
business_address: Optional[str] = None
tax_number: Optional[str] = None
# Letzshop CSV URLs (multi-language support)
letzshop_csv_url_fr: Optional[str] = None
letzshop_csv_url_en: Optional[str] = None
letzshop_csv_url_de: Optional[str] = None
# Theme configuration
theme_config: Optional[Dict] = Field(default_factory=dict)
@field_validator("owner_email")
@classmethod
def validate_owner_email(cls, v):
if not v or "@" not in v or "." not in v:
raise ValueError("Valid email address required for vendor owner")
return v.lower()
@field_validator("subdomain")
@classmethod
def validate_subdomain(cls, v):
# Basic subdomain validation: lowercase alphanumeric with hyphens
if v and not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', v):
raise ValueError("Subdomain must contain only lowercase letters, numbers, and hyphens")
return v.lower() if v else v
@field_validator("vendor_code")
@classmethod
def validate_vendor_code(cls, v):
# Ensure vendor code is uppercase for consistency
return v.upper() if v else v
class VendorUpdate(BaseModel):
"""Schema for updating vendor information."""
name: Optional[str] = None
description: Optional[str] = None
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
website: Optional[str] = None
business_address: Optional[str] = None
tax_number: Optional[str] = None
letzshop_csv_url_fr: Optional[str] = None
letzshop_csv_url_en: Optional[str] = None
letzshop_csv_url_de: Optional[str] = None
theme_config: Optional[Dict] = None
is_active: Optional[bool] = None
@field_validator("contact_email")
@classmethod
def validate_contact_email(cls, v):
if v and ("@" not in v or "." not in v):
raise ValueError("Invalid email format")
return v.lower() if v else v
class VendorResponse(BaseModel):
"""Schema for vendor response data."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_code: str
subdomain: str
name: str
description: Optional[str]
owner_user_id: int
# Contact information
contact_email: Optional[str]
contact_phone: Optional[str]
website: Optional[str]
# Business information
business_address: Optional[str]
tax_number: Optional[str]
# Letzshop URLs
letzshop_csv_url_fr: Optional[str]
letzshop_csv_url_en: Optional[str]
letzshop_csv_url_de: Optional[str]
# Theme configuration
theme_config: Dict
# Status flags
is_active: bool
is_verified: bool
# Timestamps
created_at: datetime
updated_at: datetime
class VendorListResponse(BaseModel):
"""Schema for paginated vendor list."""
vendors: List[VendorResponse]
total: int
skip: int
limit: int
class VendorSummary(BaseModel):
"""Lightweight vendor summary for dropdowns and quick references."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_code: str
subdomain: str
name: str
is_active: bool
class VendorCreateResponse(VendorResponse):
"""Extended response for vendor creation with owner credentials."""
owner_email: str
owner_username: str
temporary_password: str
login_url: Optional[str] = None