major refactoring adding vendor and customer features
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
64
models/database/customer.py
Normal file
64
models/database/customer.py
Normal 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}')>"
|
||||
41
models/database/inventory.py
Normal file
41
models/database/inventory.py
Normal 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)
|
||||
@@ -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})>"
|
||||
)
|
||||
|
||||
@@ -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
86
models/database/order.py
Normal 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})>"
|
||||
@@ -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)
|
||||
|
||||
@@ -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})>"
|
||||
@@ -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
|
||||
@@ -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})>"
|
||||
@@ -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
193
models/schemas/customer.py
Normal 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
|
||||
77
models/schemas/inventory.py
Normal file
77
models/schemas/inventory.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
170
models/schemas/order.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user