major refactoring adding vendor and customer features

This commit is contained in:
2025-10-11 09:09:25 +02:00
parent f569995883
commit dd16198276
126 changed files with 15109 additions and 3747 deletions

View File

@@ -5,7 +5,7 @@
from .database.base import Base
from .database.user import User
from .database.marketplace_product import MarketplaceProduct
from .database.stock import Stock
from .database.inventory import Inventory
from .database.vendor import Vendor
from .database.product import Product
from .database.marketplace_import_job import MarketplaceImportJob
@@ -18,7 +18,7 @@ __all__ = [
"Base",
"User",
"MarketplaceProduct",
"Stock",
"Inventory",
"Vendor",
"Product",
"MarketplaceImportJob",

View File

@@ -2,9 +2,11 @@
"""Database models package."""
from .base import Base
from .customer import Customer
from .order import Order
from .user import User
from .marketplace_product import MarketplaceProduct
from .stock import Stock
from .inventory import Inventory
from .vendor import Vendor
from .product import Product
from .marketplace_import_job import MarketplaceImportJob
@@ -13,7 +15,9 @@ __all__ = [
"Base",
"User",
"MarketplaceProduct",
"Stock",
"Inventory",
"Customer",
"Order",
"Vendor",
"Product",
"MarketplaceImportJob",

View File

@@ -0,0 +1,64 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, JSON, Numeric
from sqlalchemy.orm import relationship
from app.core.database import Base
from .base import TimestampMixin
class Customer(Base, TimestampMixin):
__tablename__ = "customers"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
email = Column(String(255), nullable=False, index=True) # Unique within vendor scope
hashed_password = Column(String(255), nullable=False)
first_name = Column(String(100))
last_name = Column(String(100))
phone = Column(String(50))
customer_number = Column(String(100), nullable=False, index=True) # Vendor-specific ID
preferences = Column(JSON, default=dict)
marketing_consent = Column(Boolean, default=False)
last_order_date = Column(DateTime)
total_orders = Column(Integer, default=0)
total_spent = Column(Numeric(10, 2), default=0)
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
vendor = relationship("Vendor", back_populates="customers")
addresses = relationship("CustomerAddress", back_populates="customer")
orders = relationship("Order", back_populates="customer")
def __repr__(self):
return f"<Customer(id={self.id}, vendor_id={self.vendor_id}, email='{self.email}')>"
@property
def full_name(self):
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.email
class CustomerAddress(Base, TimestampMixin):
__tablename__ = "customer_addresses"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
address_type = Column(String(50), nullable=False) # 'billing', 'shipping'
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
company = Column(String(200))
address_line_1 = Column(String(255), nullable=False)
address_line_2 = Column(String(255))
city = Column(String(100), nullable=False)
postal_code = Column(String(20), nullable=False)
country = Column(String(100), nullable=False)
is_default = Column(Boolean, default=False)
# Relationships
vendor = relationship("Vendor")
customer = relationship("Customer", back_populates="addresses")
def __repr__(self):
return f"<CustomerAddress(id={self.id}, customer_id={self.customer_id}, type='{self.address_type}')>"

View File

@@ -0,0 +1,41 @@
# models/database/inventory.py
from datetime import datetime
from sqlalchemy import Column, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class Inventory(Base, TimestampMixin):
__tablename__ = "inventory"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
location = Column(String, nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=0)
reserved_quantity = Column(Integer, default=0)
# Optional: Keep GTIN for reference/reporting
gtin = Column(String, index=True)
# Relationships
product = relationship("Product", back_populates="inventory_entries")
vendor = relationship("Vendor")
# Constraints
__table_args__ = (
UniqueConstraint("product_id", "location", name="uq_inventory_product_location"),
Index("idx_inventory_vendor_product", "vendor_id", "product_id"),
Index("idx_inventory_product_location", "product_id", "location"),
)
def __repr__(self):
return f"<Inventory(product_id={self.product_id}, location='{self.location}', quantity={self.quantity})>"
@property
def available_quantity(self):
"""Calculate available quantity (total - reserved)."""
return max(0, self.quantity - self.reserved_quantity)

View File

@@ -1,10 +1,8 @@
from datetime import datetime, timezone
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
Integer, String, Text, UniqueConstraint)
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
@@ -13,20 +11,17 @@ class MarketplaceImportJob(Base, TimestampMixin):
__tablename__ = "marketplace_import_jobs"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Import configuration
marketplace = Column(String, nullable=False, index=True, default="Letzshop")
source_url = Column(String, nullable=False)
# Status tracking
status = Column(
String, nullable=False, default="pending"
) # pending, processing, completed, failed, completed_with_errors
source_url = Column(String, nullable=False)
marketplace = Column(
String, nullable=False, index=True, default="Letzshop"
) # Index for marketplace filtering
vendor_name = Column(String, nullable=False, index=True) # Index for vendor filtering
vendor_id = Column(
Integer, ForeignKey("vendors.id"), nullable=False
) # Add proper foreign key
user_id = Column(
Integer, ForeignKey("users.id"), nullable=False
) # Foreign key to users table
# Results
imported_count = Column(Integer, default=0)
@@ -35,28 +30,26 @@ class MarketplaceImportJob(Base, TimestampMixin):
total_processed = Column(Integer, default=0)
# Error handling
error_message = Column(String)
error_message = Column(Text)
# Timestamps
started_at = Column(DateTime(timezone=True))
completed_at = Column(DateTime(timezone=True))
started_at = Column(DateTime)
completed_at = Column(DateTime)
# Relationship to user
user = relationship("User", foreign_keys=[user_id])
# Relationships
vendor = relationship("Vendor", back_populates="marketplace_import_jobs")
user = relationship("User", foreign_keys=[user_id])
# Additional indexes for marketplace import job queries
# Indexes for performance
__table_args__ = (
Index(
"idx_marketplace_import_user_marketplace", "user_id", "marketplace"
), # User's marketplace imports
Index("idx_marketplace_import_vendor_status", "status"), # Vendor import status
Index("idx_marketplace_import_vendor_id", "vendor_id"),
Index("idx_import_vendor_status", "vendor_id", "status"),
Index("idx_import_vendor_created", "vendor_id", "created_at"),
Index("idx_import_user_marketplace", "user_id", "marketplace"),
)
def __repr__(self):
return (
f"<MarketplaceImportJob(id={self.id}, marketplace='{self.marketplace}', vendor='{self.vendor_name}', "
f"status='{self.status}', imported={self.imported_count})>"
f"<MarketplaceImportJob(id={self.id}, vendor_id={self.vendor_id}, "
f"marketplace='{self.marketplace}', status='{self.status}', "
f"imported={self.imported_count})>"
)

View File

@@ -21,7 +21,7 @@ class MarketplaceProduct(Base, TimestampMixin):
availability = Column(String, index=True) # Index for filtering
price = Column(String)
brand = Column(String, index=True) # Index for filtering
gtin = Column(String, index=True) # Index for stock lookups
gtin = Column(String, index=True) # Index for inventory lookups
mpn = Column(String)
condition = Column(String)
adult = Column(String)
@@ -57,13 +57,6 @@ class MarketplaceProduct(Base, TimestampMixin):
) # Index for marketplace filtering
vendor_name = Column(String, index=True, nullable=True) # Index for vendor filtering
# Relationship to stock (one-to-many via GTIN)
stock_entries = relationship(
"Stock",
foreign_keys="Stock.gtin",
primaryjoin="MarketplaceProduct.gtin == Stock.gtin",
viewonly=True,
)
product = relationship("Product", back_populates="marketplace_product")
# Additional indexes for marketplace queries

86
models/database/order.py Normal file
View File

@@ -0,0 +1,86 @@
# models/database/order.py
from datetime import datetime
from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String, Text, Boolean
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class Order(Base, TimestampMixin):
"""Customer orders."""
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True)
order_number = Column(String, nullable=False, unique=True, index=True)
# Order status
status = Column(String, nullable=False, default="pending", index=True)
# pending, processing, shipped, delivered, cancelled, refunded
# Financial
subtotal = Column(Float, nullable=False)
tax_amount = Column(Float, default=0.0)
shipping_amount = Column(Float, default=0.0)
discount_amount = Column(Float, default=0.0)
total_amount = Column(Float, nullable=False)
currency = Column(String, default="EUR")
# Addresses (stored as IDs)
shipping_address_id = Column(Integer, ForeignKey("customer_addresses.id"), nullable=False)
billing_address_id = Column(Integer, ForeignKey("customer_addresses.id"), nullable=False)
# Shipping
shipping_method = Column(String, nullable=True)
tracking_number = Column(String, nullable=True)
# Notes
customer_notes = Column(Text, nullable=True)
internal_notes = Column(Text, nullable=True)
# Timestamps
paid_at = Column(DateTime, nullable=True)
shipped_at = Column(DateTime, nullable=True)
delivered_at = Column(DateTime, nullable=True)
cancelled_at = Column(DateTime, nullable=True)
# Relationships
vendor = relationship("Vendor")
customer = relationship("Customer", back_populates="orders")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
shipping_address = relationship("CustomerAddress", foreign_keys=[shipping_address_id])
billing_address = relationship("CustomerAddress", foreign_keys=[billing_address_id])
def __repr__(self):
return f"<Order(id={self.id}, order_number='{self.order_number}', status='{self.status}')>"
class OrderItem(Base, TimestampMixin):
"""Individual items in an order."""
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
# Product details at time of order (snapshot)
product_name = Column(String, nullable=False)
product_sku = Column(String, nullable=True)
quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False)
total_price = Column(Float, nullable=False)
# Inventory tracking
inventory_reserved = Column(Boolean, default=False)
inventory_fulfilled = Column(Boolean, default=False)
# Relationships
order = relationship("Order", back_populates="items")
product = relationship("Product")
def __repr__(self):
return f"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id})>"

View File

@@ -1,13 +1,12 @@
# models/database/product.py
from datetime import datetime
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
Integer, String, Text, UniqueConstraint)
from sqlalchemy import Boolean, Column, Float, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
class Product(Base, TimestampMixin):
__tablename__ = "products"
@@ -15,12 +14,12 @@ class Product(Base, TimestampMixin):
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
marketplace_product_id = Column(Integer, ForeignKey("marketplace_products.id"), nullable=False)
# Vendor-specific overrides (can override the main product data)
product_id = Column(String) # Vendor's internal product ID
price = Column(Float) # Override main product price
# Vendor-specific overrides
product_id = Column(String) # Vendor's internal SKU
price = Column(Float)
sale_price = Column(Float)
currency = Column(String)
availability = Column(String) # Override availability
availability = Column(String)
condition = Column(String)
# Vendor-specific metadata
@@ -28,13 +27,14 @@ class Product(Base, TimestampMixin):
is_active = Column(Boolean, default=True)
display_order = Column(Integer, default=0)
# Inventory management
# Inventory settings
min_quantity = Column(Integer, default=1)
max_quantity = Column(Integer)
# Relationships
vendor = relationship("Vendor", back_populates="product")
vendor = relationship("Vendor", back_populates="products")
marketplace_product = relationship("MarketplaceProduct", back_populates="product")
inventory_entries = relationship("Inventory", back_populates="product", cascade="all, delete-orphan")
# Constraints
__table_args__ = (
@@ -42,3 +42,16 @@ class Product(Base, TimestampMixin):
Index("idx_product_active", "vendor_id", "is_active"),
Index("idx_product_featured", "vendor_id", "is_featured"),
)
def __repr__(self):
return f"<Product(id={self.id}, vendor_id={self.vendor_id}, product_id='{self.product_id}')>"
@property
def total_inventory(self):
"""Calculate total inventory across all locations."""
return sum(inv.quantity for inv in self.inventory_entries)
@property
def available_inventory(self):
"""Calculate available inventory (total - reserved)."""
return sum(inv.available_quantity for inv in self.inventory_entries)

View File

@@ -1,35 +0,0 @@
from datetime import datetime
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
Integer, String, Text, UniqueConstraint)
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
class Stock(Base, TimestampMixin):
__tablename__ = "stock"
id = Column(Integer, primary_key=True, index=True)
gtin = Column(
String, index=True, nullable=False
) # Foreign key relationship would be ideal
location = Column(String, nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=0)
reserved_quantity = Column(Integer, default=0) # For orders being processed
vendor_id = Column(Integer, ForeignKey("vendors.id")) # Optional: vendor -specific stock
# Relationships
vendor = relationship("Vendor")
# Composite unique constraint to prevent duplicate GTIN-location combinations
__table_args__ = (
UniqueConstraint("gtin", "location", name="uq_stock_gtin_location"),
Index(
"idx_stock_gtin_location", "gtin", "location"
), # Composite index for efficient queries
)
def __repr__(self):
return f"<Stock(gtin='{self.gtin}', location='{self.location}', quantity={self.quantity})>"

View File

@@ -14,6 +14,8 @@ class User(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
first_name = Column(String)
last_name = Column(String)
hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default="user") # user, admin, vendor_owner
is_active = Column(Boolean, default=True, nullable=False)
@@ -23,7 +25,16 @@ class User(Base, TimestampMixin):
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="user"
)
# Vendor relationships
owned_vendors = relationship("Vendor", back_populates="owner")
vendor_memberships = relationship("VendorUser", foreign_keys="[VendorUser.user_id]", back_populates="user")
def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}', role='{self.role}')>"
return f"<User(id={self.id}, username='{self.username}', email='{self.email}', role='{self.role}')>"
@property
def full_name(self):
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.username

View File

@@ -1,7 +1,6 @@
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text)
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text, JSON)
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
@@ -13,15 +12,22 @@ class Vendor(Base, TimestampMixin):
vendor_code = Column(
String, unique=True, index=True, nullable=False
) # e.g., "TECHSTORE", "FASHIONHUB"
vendor_name = Column(String, nullable=False) # Display name
subdomain = Column(String(100), unique=True, nullable=False, index=True)
name = Column(String, nullable=False) # Display name
description = Column(Text)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
theme_config = Column(JSON, default=dict)
# Contact information
contact_email = Column(String)
contact_phone = Column(String)
website = Column(String)
# Letzshop URLs - multi-language support
letzshop_csv_url_fr = Column(String)
letzshop_csv_url_en = Column(String)
letzshop_csv_url_de = Column(String)
# Business information
business_address = Column(Text)
tax_number = Column(String)
@@ -32,7 +38,47 @@ class Vendor(Base, TimestampMixin):
# Relationships
owner = relationship("User", back_populates="owned_vendors")
product = relationship("Product", back_populates="vendor")
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="vendor"
)
vendor_users = relationship("VendorUser", back_populates="vendor")
products = relationship("Product", back_populates="vendor")
customers = relationship("Customer", back_populates="vendor")
orders = relationship("Order", back_populates="vendor")
marketplace_import_jobs = relationship("MarketplaceImportJob", back_populates="vendor")
def __repr__(self):
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"
class VendorUser(Base, TimestampMixin):
__tablename__ = "vendor_users"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
invited_by = Column(Integer, ForeignKey("users.id"))
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
vendor = relationship("Vendor", back_populates="vendor_users")
user = relationship("User", foreign_keys=[user_id], back_populates="vendor_memberships")
inviter = relationship("User", foreign_keys=[invited_by])
role = relationship("Role", back_populates="vendor_users")
def __repr__(self):
return f"<VendorUser(vendor_id={self.vendor_id}, user_id={self.user_id})>"
class Role(Base, TimestampMixin):
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
name = Column(String(100), nullable=False) # "Owner", "Manager", "Editor", "Viewer"
permissions = Column(JSON, default=list) # ["products.create", "orders.view", etc.]
# Relationships
vendor = relationship("Vendor")
vendor_users = relationship("VendorUser", back_populates="role")
def __repr__(self):
return f"<Role(id={self.id}, name='{self.name}', vendor_id={self.vendor_id})>"

View File

@@ -7,7 +7,7 @@ from . import base
from . import marketplace_import_job
from . import marketplace_product
from . import stats
from . import stock
from . import inventory
from . import vendor
# Common imports for convenience
from .base import * # Base Pydantic models
@@ -16,7 +16,7 @@ __all__ = [
"base",
"auth",
"marketplace_product",
"stock",
"inventory.py",
"vendor",
"marketplace_import_job",
"stats",

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

@@ -0,0 +1,193 @@
# models/schemas/customer.py
"""
Pydantic schemas 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

@@ -1,41 +1,71 @@
# models/schemas/marketplace_import_job.py - Keep URL validation, remove business constraints
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
class MarketplaceImportJobRequest(BaseModel):
url: str = Field(..., description="URL to CSV file from marketplace")
marketplace: str = Field(default="Letzshop", description="Marketplace name")
vendor_code: str = Field(..., description="Vendor code to associate products with")
batch_size: Optional[int] = Field(1000, description="Processing batch size")
# Removed: gt=0, le=10000 constraints - let service handle
"""Request schema for triggering marketplace import.
@field_validator("url")
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):
# Keep URL format validation for security
# Basic URL security validation
if not v.startswith(("http://", "https://")):
raise ValueError("URL must start with http:// or https://")
return v
@field_validator("marketplace", "vendor_code")
@classmethod
def validate_strings(cls, v):
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
status: str
marketplace: str
vendor_id: int
vendor_code: Optional[str] = None
vendor_name: str
message: Optional[str] = None
imported: Optional[int] = 0
updated: Optional[int] = 0
total_processed: Optional[int] = 0
error_count: Optional[int] = 0
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
created_at: Optional[datetime] = 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

@@ -2,7 +2,7 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
from models.schemas.stock import StockSummaryResponse
from models.schemas.inventory import ProductInventorySummary
class MarketplaceProductBase(BaseModel):
marketplace_product_id: Optional[str] = None
@@ -68,4 +68,4 @@ class MarketplaceProductListResponse(BaseModel):
class MarketplaceProductDetailResponse(BaseModel):
product: MarketplaceProductResponse
stock_info: Optional[StockSummaryResponse] = None
inventory_info: Optional[ProductInventorySummary] = None

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

@@ -0,0 +1,170 @@
# models/schemas/order.py
"""
Pydantic schemas 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

View File

@@ -1,25 +1,40 @@
# product.py - Keep basic format validation, remove business logic
import re
# models/schema/product.py
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field
from models.schemas.marketplace_product import MarketplaceProductResponse
from models.schemas.inventory import InventoryLocationResponse
class ProductCreate(BaseModel):
marketplace_product_id: str = Field(..., description="MarketplaceProduct ID to add to vendor ")
product_id: Optional[str] = None
price: Optional[float] = None # Removed: ge=0 constraint
sale_price: Optional[float] = None # Removed: ge=0 constraint
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 = Field(False, description="Featured product flag")
min_quantity: int = Field(1, description="Minimum order quantity")
max_quantity: Optional[int] = None # Removed: ge=1 constraint
# Service will validate price ranges and quantity logic
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
@@ -31,7 +46,24 @@ class ProductResponse(BaseModel):
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

View File

@@ -11,7 +11,7 @@ class StatsResponse(BaseModel):
unique_categories: int
unique_marketplaces: int = 0
unique_vendors: int = 0
total_stock_entries: int = 0
total_inventory_entries: int = 0
total_inventory_quantity: int = 0

View File

@@ -1,39 +0,0 @@
# stock.py - Remove business logic validation constraints
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
class StockBase(BaseModel):
gtin: str = Field(..., description="GTIN identifier")
location: str = Field(..., description="Storage location")
class StockCreate(StockBase):
quantity: int = Field(..., description="Initial stock quantity")
# Removed: ge=0 constraint - let service handle negative validation
class StockAdd(StockBase):
quantity: int = Field(..., description="Quantity to add/remove")
# Removed: gt=0 constraint - let service handle zero/negative validation
class StockUpdate(BaseModel):
quantity: int = Field(..., description="New stock quantity")
# Removed: ge=0 constraint - let service handle negative validation
class StockResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
gtin: str
location: str
quantity: int
created_at: datetime
updated_at: datetime
class StockLocationResponse(BaseModel):
location: str
quantity: int
class StockSummaryResponse(BaseModel):
gtin: str
total_quantity: int
locations: List[StockLocationResponse]
product_title: Optional[str] = None

View File

@@ -1,36 +1,72 @@
# vendor.py - Keep basic format validation, remove business logic
# models/schemas/vendor.py
import re
from datetime import datetime
from typing import List, Optional
from typing import Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
class VendorCreate(BaseModel):
vendor_code: str = Field(..., description="Unique vendor identifier")
vendor_name: str = Field(..., description="Display name of the vendor ")
description: Optional[str] = Field(None, description="Vendor description")
contact_email: Optional[str] = None
"""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
# Removed: min_length, max_length constraints - let service handle
@field_validator("contact_email")
# 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_contact_email(cls, v):
# Keep basic format validation for data integrity
if v and ("@" not in v or "." not in v):
raise ValueError("Invalid email format")
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):
vendor_name: Optional[str] = None
"""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
@@ -39,25 +75,66 @@ class VendorUpdate(BaseModel):
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
vendor_name: str
subdomain: str
name: str
description: Optional[str]
owner_id: int
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