from pydantic import BaseModel, Field, model_validator from typing import Optional, List, Dict, Any from decimal import Decimal from datetime import datetime, date # --- Brand --- class BrandCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) logo_url: Optional[str] = Field(None, max_length=500) device_types: Optional[List[str]] = None class BrandUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) logo_url: Optional[str] = Field(None, max_length=500) is_active: Optional[bool] = None device_types: Optional[List[str]] = None class BrandResponse(BaseModel): brand_id: str name: str slug: str logo_url: Optional[str] is_active: bool device_types: List[str] = [] created_at: Optional[datetime] = None class Config: from_attributes = True # --- Category --- class CategoryCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) parent_category_id: Optional[str] = Field(None, min_length=26, max_length=26) description: Optional[str] = Field(None, max_length=500) image_url: Optional[str] = Field(None, max_length=500) sort_order: Optional[str] = "0" is_parent_feature: bool = False class CategoryUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) parent_category_id: Optional[str] = Field(None, min_length=26, max_length=26) description: Optional[str] = Field(None, max_length=500) image_url: Optional[str] = Field(None, max_length=500) sort_order: Optional[str] = None is_active: Optional[bool] = None is_parent_feature: Optional[bool] = None class CategoryResponse(BaseModel): category_id: str parent_category_id: Optional[str] name: str slug: str description: Optional[str] image_url: Optional[str] sort_order: str is_parent_feature: bool = False is_active: bool created_at: Optional[datetime] = None class Config: from_attributes = True # --- Category Parent Hierarchy (nested response) --- class HierarchyModelItem(BaseModel): model_id: str name: str slug: str image_url: Optional[str] = None class HierarchySeriesItem(BaseModel): series_id: str name: str slug: str models: List[HierarchyModelItem] = [] class HierarchyBrandItem(BaseModel): brand_id: str name: str slug: str logo_url: Optional[str] = None series: List[HierarchySeriesItem] = [] class CategoryParentHierarchyResponse(BaseModel): category_id: str brands: List[HierarchyBrandItem] = [] # --- Tag --- class TagCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) class TagResponse(BaseModel): tag_id: str name: str slug: str is_active: bool class Config: from_attributes = True # --- Collection --- class CollectionCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) description: Optional[str] = Field(None, max_length=500) class CollectionResponse(BaseModel): collection_id: str name: str slug: str description: Optional[str] is_active: bool class Config: from_attributes = True # --- SEO Metadata --- class SeoMetadataCreate(BaseModel): entity_type: str = Field(..., description="product, category, brand, device_model, repair_service") entity_id: str meta_title: str = Field(..., max_length=255) meta_description: str canonical_url: Optional[str] = Field(None, max_length=500) og_image: Optional[str] = Field(None, max_length=500) schema_markup: Optional[Dict[str, Any]] = Field(None, alias="schema_json") class Config: populate_by_name = True from_attributes = True class SeoMetadataResponse(BaseModel): seo_id: str entity_type: str entity_id: str meta_title: str meta_description: str canonical_url: Optional[str] og_image: Optional[str] schema_markup: Optional[Dict[str, Any]] = Field(None, alias="schema_json") class Config: populate_by_name = True from_attributes = True # --- Part --- class PartCreate(BaseModel): sku: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=255) cost_price: Decimal low_stock_alert: Optional[int] = 3 supplier: Optional[str] = Field(None, max_length=255) barcode: Optional[str] = Field(None, max_length=100) class PartUpdate(BaseModel): sku: Optional[str] = Field(None, min_length=1, max_length=100) name: Optional[str] = Field(None, min_length=1, max_length=255) cost_price: Optional[Decimal] = None low_stock_alert: Optional[int] = None supplier: Optional[str] = None barcode: Optional[str] = None is_active: Optional[bool] = None class PartResponse(BaseModel): part_id: str sku: str name: str cost_price: Decimal low_stock_alert: int supplier: Optional[str] barcode: Optional[str] is_active: bool stock: int = 0 # computed from stock ledger class Config: from_attributes = True # --- Part Compatibility --- class PartDeviceCompatibilityCreate(BaseModel): part_id: str model_id: str class PartDeviceCompatibilityResponse(BaseModel): id: str part_id: str model_id: str class Config: from_attributes = True # --- Stock Movement --- class StockMovementCreate(BaseModel): entity_type: str = Field(..., description="variant or part") entity_id: str movement_type: str = Field(..., description="Purchase, Sale, Repair, Adjustment, Return, Transfer, Damage") quantity: int reference_type: str = Field(..., description="Order, PurchaseOrder, RepairBooking, ManualAdjustment") reference_id: str class StockMovementResponse(BaseModel): movement_id: str entity_type: str entity_id: str movement_type: str quantity: int reference_type: str reference_id: str created_at: datetime class Config: from_attributes = True # --- Purchase Order --- class PurchaseOrderItemCreate(BaseModel): part_id: str quantity_ordered: int unit_price: Decimal class PurchaseOrderCreate(BaseModel): supplier_name: str = Field(..., min_length=1, max_length=255) items: List[PurchaseOrderItemCreate] class PurchaseOrderUpdate(BaseModel): status: Optional[str] = None # Draft, Ordered, Received, Cancelled items_received: Optional[List[Dict[str, Any]]] = None # list of {"id": item_id, "quantity_received": int} class PurchaseOrderItemResponse(BaseModel): id: str part_id: str quantity_ordered: int quantity_received: int unit_price: Decimal class Config: from_attributes = True class PurchaseOrderResponse(BaseModel): purchase_order_id: str po_number: str supplier_name: str status: str total_amount: Decimal created_at: datetime items: List[PurchaseOrderItemResponse] class Config: from_attributes = True # --- Device Series --- class DeviceSeriesCreate(BaseModel): brand_id: str name: str = Field(..., min_length=1, max_length=100) device_type: Optional[str] = None # laptop, tablet, mobile sort_order: Optional[int] = 0 class DeviceSeriesUpdate(BaseModel): brand_id: Optional[str] = None name: Optional[str] = None device_type: Optional[str] = None sort_order: Optional[int] = None is_active: Optional[bool] = None class DeviceSeriesResponse(BaseModel): series_id: str brand_id: Optional[str] = None name: str slug: str device_type: Optional[str] = None sort_order: int is_active: bool class Config: from_attributes = True # --- Device Model --- class DeviceModelCreate(BaseModel): series_id: Optional[str] = None brand_id: Optional[str] = None name: str = Field(..., min_length=1, max_length=100) device_type: Optional[str] = None # laptop, tablet, mobile release_year: Optional[int] = None image_url: Optional[str] = Field(None, max_length=500) class DeviceModelUpdate(BaseModel): series_id: Optional[str] = None brand_id: Optional[str] = None name: Optional[str] = None device_type: Optional[str] = None release_year: Optional[int] = None image_url: Optional[str] = None is_active: Optional[bool] = None class DeviceModelResponse(BaseModel): model_id: str series_id: Optional[str] = None brand_id: Optional[str] = None name: str slug: str device_type: Optional[str] = None full_path: str release_year: Optional[int] image_url: Optional[str] is_active: bool class Config: from_attributes = True # --- Service Type --- class ServiceTypeCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) icon_url: Optional[str] = Field(None, max_length=500) description: Optional[str] = Field(None, max_length=500) class ServiceTypeUpdate(BaseModel): name: Optional[str] = None icon_url: Optional[str] = None description: Optional[str] = None is_active: Optional[bool] = None class ServiceTypeResponse(BaseModel): service_type_id: str name: str slug: str icon_url: Optional[str] description: Optional[str] is_active: bool class Config: from_attributes = True # --- Repair Service --- class RepairServiceCreate(BaseModel): model_id: str service_type_id: str description: Optional[str] = Field(None, max_length=1000) class RepairServiceUpdate(BaseModel): model_id: Optional[str] = None service_type_id: Optional[str] = None description: Optional[str] = None class RepairServiceResponse(BaseModel): repair_service_id: str model_id: str service_type_id: str slug: str full_path: str description: Optional[str] class Config: from_attributes = True # --- Repair Variant & BOM Part --- class RepairVariantPartCreate(BaseModel): part_id: str quantity: Optional[int] = 1 class RepairVariantCreate(BaseModel): repair_service_id: str name: str = Field(..., min_length=1, max_length=100) price: Decimal cost: Optional[Decimal] = Decimal(0.0) duration_minutes: Optional[int] = 45 warranty_days: Optional[int] = 90 parts: Optional[List[RepairVariantPartCreate]] = [] class RepairVariantUpdate(BaseModel): repair_service_id: Optional[str] = None name: Optional[str] = None price: Optional[Decimal] = None cost: Optional[Decimal] = None duration_minutes: Optional[int] = None warranty_days: Optional[int] = None parts: Optional[List[RepairVariantPartCreate]] = None status: Optional[str] = None class RepairVariantPartResponse(BaseModel): id: str variant_id: str part_id: str quantity: int class Config: from_attributes = True class RepairVariantResponse(BaseModel): variant_id: str repair_service_id: str name: str price: Decimal cost: Decimal duration_minutes: int warranty_days: int status: str created_at: datetime bom_parts: List[RepairVariantPartResponse] = [] class Config: from_attributes = True # --- Attribute Type --- class AttributeTypeCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) code: str = Field(..., min_length=1, max_length=100) preset_values: Optional[List[str]] = [] class AttributeTypeUpdate(BaseModel): name: Optional[str] = None code: Optional[str] = None preset_values: Optional[List[str]] = None class AttributeTypeResponse(BaseModel): attribute_id: str name: str code: str status: str preset_values: Optional[List[str]] = [] class Config: from_attributes = True # --- Variant Attribute --- class VariantAttributeCreate(BaseModel): attribute_id: str attribute_value: str = Field(..., min_length=1, max_length=255) class VariantAttributeResponse(BaseModel): id: str variant_id: str attribute_id: str attribute_value: str attribute_name: Optional[str] = None attribute_code: Optional[str] = None @model_validator(mode="before") @classmethod def resolve_attribute_type(cls, data: Any) -> Any: if not isinstance(data, dict) and hasattr(data, "attribute_type") and data.attribute_type: return { "id": data.id, "variant_id": data.variant_id, "attribute_id": data.attribute_id, "attribute_value": data.attribute_value, "attribute_name": data.attribute_type.name, "attribute_code": data.attribute_type.code } return data class Config: from_attributes = True # --- Product Image --- class ProductImageCreate(BaseModel): image_url: str = Field(..., max_length=500) alt_text: Optional[str] = Field(None, max_length=255) sort_order: Optional[int] = 0 is_banner: Optional[bool] = False class ProductImageResponse(BaseModel): image_id: str product_id: str image_url: str alt_text: Optional[str] sort_order: int is_banner: bool class Config: from_attributes = True # --- Variant Image --- class VariantImageCreate(BaseModel): image_url: str = Field(..., max_length=500) sort_order: Optional[int] = 0 is_primary: Optional[bool] = False class VariantImageResponse(BaseModel): image_id: str variant_id: str image_url: str sort_order: int is_primary: bool class Config: from_attributes = True # --- Product Variant --- class ProductVariantCreate(BaseModel): sku: str = Field(..., min_length=1, max_length=100) barcode: Optional[str] = Field(None, max_length=100) price: Decimal compare_price: Optional[Decimal] = None cost_price: Decimal low_stock_threshold: Optional[int] = 5 initial_stock: Optional[int] = Field(None, ge=0, description="Sellable units; create defaults to 0, update omitted means leave ledger unchanged") attributes: List[VariantAttributeCreate] images: Optional[List[VariantImageCreate]] = [] class ProductVariantResponse(BaseModel): variant_id: str product_id: str sku: str barcode: Optional[str] price: Decimal compare_price: Optional[Decimal] cost_price: Decimal low_stock_threshold: int status: str available_stock: int = 0 attributes: List[VariantAttributeResponse] = [] images: List[VariantImageResponse] = [] class Config: from_attributes = True # --- Product --- class ProductCreate(BaseModel): category_id: str brand_id: Optional[str] = None device_series_id: Optional[str] = None device_model_id: Optional[str] = None device_type: Optional[str] = None # laptop, tablet, mobile name: str = Field(..., min_length=1, max_length=255) description: Optional[str] = Field(None, max_length=1000) images: Optional[List[ProductImageCreate]] = [] variants: List[ProductVariantCreate] class ProductResponse(BaseModel): product_id: str category_id: str brand_id: Optional[str] device_series_id: Optional[str] device_model_id: Optional[str] device_type: Optional[str] = None name: str slug: str full_path: str description: Optional[str] status: str created_at: Optional[datetime] = None images: List[ProductImageResponse] = [] variants: List[ProductVariantResponse] = [] @model_validator(mode="after") def populate_images(self) -> 'ProductResponse': if not self.images and self.variants: first_var = self.variants[0] if first_var.images: self.images = [ ProductImageResponse( image_id=vi.image_id, product_id=self.product_id, image_url=vi.image_url, alt_text=self.name, sort_order=vi.sort_order, is_banner=vi.is_primary ) for vi in first_var.images ] return self class Config: from_attributes = True class ProductCardResponse(BaseModel): product_id: str category_id: Optional[str] = None slug: str name: str thumbnail_url: Optional[str] = None price: Decimal compare_price: Optional[Decimal] = None discount_percent: int = 0 rating: float = 5.0 stock_count: int = 10 first_variant_id: Optional[str] = None badge: Optional[str] = None brand_name: Optional[str] = None class Config: from_attributes = True class ProductPaginatedResponse(BaseModel): total: int page: int limit: int cursor: Optional[str] = None products: List[ProductCardResponse]