import json from pydantic import BaseModel, model_validator, field_validator from typing import Optional, List, Any class CategoryCreate(BaseModel): name: str color: Optional[str] = None sort_order: int = 0 parent_id: Optional[int] = None general_sort_order: int = 0 auto_expanded: bool = False class CategoryUpdate(BaseModel): name: Optional[str] = None color: Optional[str] = None sort_order: Optional[int] = None parent_id: Optional[int] = None general_sort_order: Optional[int] = None auto_expanded: Optional[bool] = None class CategoryOut(BaseModel): id: int name: str color: Optional[str] = None sort_order: int = 0 parent_id: Optional[int] = None general_sort_order: int = 0 auto_expanded: bool = False model_config = {"from_attributes": True} class CategoryReparentRequest(BaseModel): parent_id: Optional[int] = None class CategoryReorderItem(BaseModel): id: int sort_order: int class SubcategoryReorderItem(BaseModel): id: int sort_order: int # position among subcategories within the parent class ParentGeneralReorderItem(BaseModel): id: int # parent category id general_sort_order: int # ── Modifier Groups ─────────────────────────────────────────────────────────── class ModifierGroupCreate(BaseModel): modifier_type: str # "option" | "ingredient" | "preference" name: str sort_order: int = 0 class ModifierGroupOut(BaseModel): id: int product_id: int modifier_type: str name: str sort_order: int = 0 model_config = {"from_attributes": True} # ── Quick Options ───────────────────────────────────────────────────────────── class ProductQuickOptionCreate(BaseModel): name: str price: float = 0.0 allow_multiple: bool = False sort_order: int = 0 is_favorite: bool = False favorite_sort_order: int = 0 is_compact: bool = False class ProductQuickOptionOut(BaseModel): id: int product_id: int name: str price: float = 0.0 allow_multiple: bool = False sort_order: int = 0 is_favorite: bool = False favorite_sort_order: int = 0 is_compact: bool = False model_config = {"from_attributes": True} # ── Options ────────────────────────────────────────────────────────────────── class OptionSubChoice(BaseModel): name: str extra_cost: float = 0.0 is_default: bool = False # When True waiter can add more than 1 of this sub-choice allow_multiple: bool = False # When True renders as half-width card on the PWA is_compact: bool = False class ProductOptionBase(BaseModel): name: str extra_cost: float = 0.0 allow_multiple: bool = False # When True waiter can select more than one sub-choice at once multi_select: bool = False is_favorite: bool = False favorite_sort_order: int = 0 # When True renders the option itself as half-width card on the PWA is_compact: bool = False group_id: Optional[int] = None class ProductOptionCreate(ProductOptionBase): sub_choices: List[OptionSubChoice] = [] class ProductOptionOut(ProductOptionBase): id: int product_id: int sub_choices: List[OptionSubChoice] = [] model_config = {"from_attributes": True} @model_validator(mode='before') @classmethod def parse_option_sub_choices(cls, data: Any) -> Any: if hasattr(data, 'sub_choices'): raw = data.sub_choices parsed = json.loads(raw) if isinstance(raw, str) else [] return { 'id': data.id, 'product_id': data.product_id, 'name': data.name, 'extra_cost': data.extra_cost, 'allow_multiple': getattr(data, 'allow_multiple', False) or False, 'multi_select': getattr(data, 'multi_select', False) or False, 'sub_choices': parsed, 'is_favorite': getattr(data, 'is_favorite', False) or False, 'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0, 'is_compact': getattr(data, 'is_compact', False) or False, 'group_id': getattr(data, 'group_id', None), } return data # ── Ingredients ─────────────────────────────────────────────────────────────── class ProductIngredientBase(BaseModel): name: str extra_cost: float = 0.0 is_favorite: bool = False favorite_sort_order: int = 0 # When True renders as half-width card on the PWA is_compact: bool = False group_id: Optional[int] = None class ProductIngredientCreate(ProductIngredientBase): pass class ProductIngredientOut(ProductIngredientBase): id: int product_id: int model_config = {"from_attributes": True} # ── Sub-choices (nested under a preference choice) ──────────────────────────── class SubChoice(BaseModel): name: str extra_cost: float = 0.0 is_default: bool = False is_compact: bool = False # ── Shared subset (set-level, shown for all non-disabling choices) ───────────── class SharedSubsetChoice(BaseModel): name: str extra_cost: float = 0.0 is_default: bool = False is_compact: bool = False class SharedSubset(BaseModel): name: str choices: List[SharedSubsetChoice] = [] # ── Preferences ─────────────────────────────────────────────────────────────── class PreferenceChoiceCreate(BaseModel): name: str extra_cost: float = 0.0 sub_choices: List[SubChoice] = [] disables_subset: bool = False # When True renders as half-width card on the PWA is_compact: bool = False class PreferenceChoiceOut(BaseModel): id: int set_id: int name: str extra_cost: float = 0.0 sub_choices: List[SubChoice] = [] disables_subset: bool = False is_compact: bool = False model_config = {"from_attributes": True} @model_validator(mode='before') @classmethod def parse_sub_choices(cls, data: Any) -> Any: if hasattr(data, 'sub_choices'): raw = data.sub_choices if isinstance(raw, str): try: parsed = json.loads(raw) except Exception: parsed = [] else: parsed = [] return { 'id': data.id, 'set_id': data.set_id, 'name': data.name, 'extra_cost': data.extra_cost, 'sub_choices': parsed, 'disables_subset': data.disables_subset or False, 'is_compact': getattr(data, 'is_compact', False) or False, } return data class PreferenceSetCreate(BaseModel): name: str choices: List[PreferenceChoiceCreate] = [] default_choice_index: Optional[int] = None # index into choices (0-based) shared_subset: Optional[SharedSubset] = None is_favorite: bool = False favorite_sort_order: int = 0 group_id: Optional[int] = None allow_multi_select: bool = False allow_choice_quantity: bool = False class PreferenceSetOut(BaseModel): id: int product_id: int name: str choices: List[PreferenceChoiceOut] = [] default_choice_id: Optional[int] = None shared_subset: Optional[SharedSubset] = None is_favorite: bool = False favorite_sort_order: int = 0 group_id: Optional[int] = None allow_multi_select: bool = False allow_choice_quantity: bool = False model_config = {"from_attributes": True} @model_validator(mode='before') @classmethod def parse_shared_subset(cls, data: Any) -> Any: if hasattr(data, 'shared_subset'): raw = data.shared_subset if isinstance(raw, str): try: parsed = json.loads(raw) except Exception: parsed = None else: parsed = None return { 'id': data.id, 'product_id': data.product_id, 'name': data.name, 'choices': list(data.choices), 'default_choice_id': data.default_choice_id, 'shared_subset': parsed, 'is_favorite': getattr(data, 'is_favorite', False) or False, 'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0, 'group_id': getattr(data, 'group_id', None), 'allow_multi_select': getattr(data, 'allow_multi_select', False) or False, 'allow_choice_quantity': getattr(data, 'allow_choice_quantity', False) or False, } return data # ── Products ────────────────────────────────────────────────────────────────── class CostBreakdownItem(BaseModel): label: str amount: float class ProductBase(BaseModel): name: str description: Optional[str] = None category_id: Optional[int] = None base_price: float # piece | portion | kg | liter | gram | ml unit_type: str = "piece" is_available: bool = True lifecycle_status: str = "active" printer_zone_id: Optional[int] = None # kept for backward compat; use prep_zone_ids instead prep_zone_ids: List[int] = [] sort_order: int = 0 # Xenia Connect — digital menu overrides digital_visible: bool = True digital_available: bool = True digital_name: Optional[str] = None digital_description: Optional[str] = None digital_price: Optional[float] = None digital_discount: Optional[float] = None digital_image_url: Optional[str] = None # Phase 2A — cost tracking cost_simple: Optional[float] = None cost_breakdown: Optional[List[CostBreakdownItem]] = None # Quick Add quick_add_enabled: bool = True # Tags tags: Optional[List[str]] = None # Service item flag is_service_item: bool = False # Fiscal printer (ΦΗΜ) fiscal_name: Optional[str] = None fiscal_vat_group_id: Optional[int] = None class ProductCreate(ProductBase): quick_options: List[ProductQuickOptionCreate] = [] options: List[ProductOptionCreate] = [] ingredients: List[ProductIngredientCreate] = [] preference_sets: List[PreferenceSetCreate] = [] # group_id on options/ingredients/preference_sets is an index into this list modifier_groups: List[ModifierGroupCreate] = [] class ProductUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None category_id: Optional[int] = None base_price: Optional[float] = None unit_type: Optional[str] = None is_available: Optional[bool] = None lifecycle_status: Optional[str] = None printer_zone_id: Optional[int] = None prep_zone_ids: Optional[List[int]] = None sort_order: Optional[int] = None quick_options: Optional[List[ProductQuickOptionCreate]] = None options: Optional[List[ProductOptionCreate]] = None ingredients: Optional[List[ProductIngredientCreate]] = None preference_sets: Optional[List[PreferenceSetCreate]] = None modifier_groups: Optional[List[ModifierGroupCreate]] = None # Xenia Connect — digital menu overrides digital_visible: Optional[bool] = None digital_available: Optional[bool] = None digital_name: Optional[str] = None digital_description: Optional[str] = None digital_price: Optional[float] = None digital_discount: Optional[float] = None digital_image_url: Optional[str] = None # Phase 2A — cost tracking cost_simple: Optional[float] = None cost_breakdown: Optional[List[CostBreakdownItem]] = None # Quick Add quick_add_enabled: Optional[bool] = None # Tags tags: Optional[List[str]] = None # Service item flag is_service_item: Optional[bool] = None # Fiscal printer (ΦΗΜ) fiscal_name: Optional[str] = None fiscal_vat_group_id: Optional[int] = None class ProductReorderItem(BaseModel): id: int sort_order: int class ProductOut(ProductBase): id: int quick_options: List[ProductQuickOptionOut] = [] options: List[ProductOptionOut] = [] ingredients: List[ProductIngredientOut] = [] preference_sets: List[PreferenceSetOut] = [] modifier_groups: List[ModifierGroupOut] = [] image_url: Optional[str] = None model_config = {"from_attributes": True} @model_validator(mode='before') @classmethod def parse_cost_breakdown(cls, data: Any) -> Any: if hasattr(data, 'cost_breakdown'): raw = data.cost_breakdown parsed = None if isinstance(raw, str): try: parsed = json.loads(raw) except Exception: parsed = None # Return a plain dict so other validators still see all fields return { 'id': data.id, 'name': data.name, 'description': data.description, 'category_id': data.category_id, 'base_price': data.base_price, 'unit_type': getattr(data, 'unit_type', 'piece') or 'piece', 'is_available': data.is_available, 'lifecycle_status': data.lifecycle_status, 'printer_zone_id': data.printer_zone_id, 'prep_zone_ids': [z.id for z in data.prep_zones] if hasattr(data, 'prep_zones') else [], 'sort_order': data.sort_order, 'image_url': data.image_url, 'digital_visible': bool(data.digital_visible), 'digital_available': bool(data.digital_available), 'digital_name': data.digital_name, 'digital_description': data.digital_description, 'digital_price': data.digital_price, 'digital_discount': data.digital_discount, 'digital_image_url': data.digital_image_url, 'cost_simple': data.cost_simple, 'cost_breakdown': parsed, 'quick_add_enabled': getattr(data, 'quick_add_enabled', True), 'tags': json.loads(data.tags) if isinstance(getattr(data, 'tags', None), str) else (getattr(data, 'tags', None) or []), 'is_service_item': bool(getattr(data, 'is_service_item', False)), 'fiscal_name': getattr(data, 'fiscal_name', None), 'fiscal_vat_group_id': getattr(data, 'fiscal_vat_group_id', None), 'quick_options': list(data.quick_options), 'options': list(data.options), 'ingredients': list(data.ingredients), 'preference_sets': list(data.preference_sets), 'modifier_groups': list(data.modifier_groups) if hasattr(data, 'modifier_groups') else [], } return data