diff --git a/local_backend/models/product.py b/local_backend/models/product.py index 5c77efc..a2c2895 100644 --- a/local_backend/models/product.py +++ b/local_backend/models/product.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey +from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey, Text from sqlalchemy.orm import relationship from database import Base @@ -24,6 +24,7 @@ class Product(Base): is_available = Column(Boolean, default=True, nullable=False) printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True) image_url = Column(String, nullable=True) + sort_order = Column(Integer, default=0, nullable=False) category = relationship("Category", back_populates="products") printer_zone = relationship("Printer", back_populates="products") @@ -40,6 +41,8 @@ class ProductOption(Base): product_id = Column(Integer, ForeignKey("products.id"), nullable=False) name = Column(String, nullable=False) extra_cost = Column(Float, default=0.0) + # JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked + sub_choices = Column(Text, nullable=True) product = relationship("Product", back_populates="options") @@ -61,6 +64,10 @@ class ProductPreferenceSet(Base): id = Column(Integer, primary_key=True, index=True) product_id = Column(Integer, ForeignKey("products.id"), nullable=False) name = Column(String, nullable=False) + default_choice_id = Column(Integer, nullable=True) + # JSON: {name, default_choice_index, choices:[{name,extra_cost,is_default}]} + # Shared sub-set shown for all choices that don't have disables_subset=True + shared_subset = Column(Text, nullable=True) product = relationship("Product", back_populates="preference_sets") choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan") @@ -73,5 +80,10 @@ class ProductPreferenceChoice(Base): set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False) name = Column(String, nullable=False) extra_cost = Column(Float, default=0.0) + # JSON array of sub-choice objects: [{name, extra_cost, is_default}] + # Per-choice inline sub-preference shown only when this choice is selected. + sub_choices = Column(Text, nullable=True) + # When True this choice hides the set-level shared_subset on the PWA. + disables_subset = Column(Boolean, default=False, nullable=False) set = relationship("ProductPreferenceSet", back_populates="choices") diff --git a/local_backend/requirements.txt b/local_backend/requirements.txt index 17f0335..714a374 100644 --- a/local_backend/requirements.txt +++ b/local_backend/requirements.txt @@ -7,3 +7,4 @@ Pillow==10.4.0 bcrypt==4.2.0 pyjwt==2.9.0 httpx==0.27.2 +python-multipart==0.0.9 diff --git a/local_backend/routers/products.py b/local_backend/routers/products.py index 10665f3..6a2dce0 100644 --- a/local_backend/routers/products.py +++ b/local_backend/routers/products.py @@ -1,14 +1,16 @@ import os import uuid +import json from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from sqlalchemy.orm import Session from typing import List from database import get_db from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice +from models.order import OrderItem from models.user import User from schemas.product import ( - ProductCreate, ProductUpdate, ProductOut, + ProductCreate, ProductUpdate, ProductOut, ProductReorderItem, CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem, PreferenceSetCreate, ) @@ -24,7 +26,13 @@ def _replace_options(db, product, options): db.delete(opt) db.flush() for opt in options: - db.add(ProductOption(product_id=product.id, **opt.model_dump())) + sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None + db.add(ProductOption( + product_id=product.id, + name=opt.name, + extra_cost=opt.extra_cost, + sub_choices=sub_json, + )) def _replace_ingredients(db, product, ingredients): @@ -40,11 +48,29 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]): db.delete(ps) db.flush() for ps in sets: - new_set = ProductPreferenceSet(product_id=product.id, name=ps.name) + shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None + new_set = ProductPreferenceSet( + product_id=product.id, + name=ps.name, + shared_subset=shared_json, + ) db.add(new_set) db.flush() + created_choices = [] for ch in ps.choices: - db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump())) + sub_json = json.dumps([s.model_dump() for s in ch.sub_choices]) if ch.sub_choices else None + choice = ProductPreferenceChoice( + set_id=new_set.id, + name=ch.name, + extra_cost=ch.extra_cost, + sub_choices=sub_json, + disables_subset=ch.disables_subset, + ) + db.add(choice) + db.flush() + created_choices.append(choice) + if ps.default_choice_index is not None and 0 <= ps.default_choice_index < len(created_choices): + new_set.default_choice_id = created_choices[ps.default_choice_index].id # ── Categories ──────────────────────────────────────────────────────────────── @@ -101,25 +127,32 @@ def list_products(all: bool = False, db: Session = Depends(get_db), user: User = q = db.query(Product) if not all or user.role not in ("manager", "sysadmin"): q = q.filter(Product.is_available == True) - return q.all() + return q.order_by(Product.sort_order, Product.id).all() + + +@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT) +def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)): + for item in items: + product = db.query(Product).filter(Product.id == item.id).first() + if product: + product.sort_order = item.sort_order + db.commit() @router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED) def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): data = body.model_dump(exclude={"options", "ingredients", "preference_sets"}) + if data.get("sort_order") == 0: + data["sort_order"] = db.query(Product).count() product = Product(**data) db.add(product) db.flush() for opt in body.options: - db.add(ProductOption(product_id=product.id, **opt.model_dump())) + sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None + db.add(ProductOption(product_id=product.id, name=opt.name, extra_cost=opt.extra_cost, sub_choices=sub_json)) for ing in body.ingredients: db.add(ProductIngredient(product_id=product.id, **ing.model_dump())) - for ps in body.preference_sets: - new_set = ProductPreferenceSet(product_id=product.id, name=ps.name) - db.add(new_set) - db.flush() - for ch in ps.choices: - db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump())) + _replace_preference_sets(db, product, body.preference_sets) db.commit() db.refresh(product) return product @@ -154,7 +187,6 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db os.makedirs(IMAGE_DIR, exist_ok=True) - # Delete old image if exists if product.image_url: old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url)) if os.path.exists(old_path): @@ -175,9 +207,18 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db @router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT) -def deactivate_product(product_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): +def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)): product = db.query(Product).filter(Product.id == product_id).first() if not product: raise HTTPException(status_code=404, detail="Product not found") - product.is_available = False + if hard: + has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first() + if has_orders: + raise HTTPException( + status_code=400, + detail="Cannot permanently delete a product that appears in past orders. Deactivate it instead." + ) + db.delete(product) + else: + product.is_available = False db.commit() diff --git a/local_backend/schemas/product.py b/local_backend/schemas/product.py index 86acf91..7847973 100644 --- a/local_backend/schemas/product.py +++ b/local_backend/schemas/product.py @@ -1,5 +1,6 @@ -from pydantic import BaseModel -from typing import Optional, List +import json +from pydantic import BaseModel, model_validator, field_validator +from typing import Optional, List, Any class CategoryCreate(BaseModel): @@ -30,21 +31,43 @@ class CategoryReorderItem(BaseModel): # ── Options ────────────────────────────────────────────────────────────────── +class OptionSubChoice(BaseModel): + name: str + extra_cost: float = 0.0 + is_default: bool = False + + class ProductOptionBase(BaseModel): name: str extra_cost: float = 0.0 class ProductOptionCreate(ProductOptionBase): - pass + 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, + 'sub_choices': parsed, + } + return data + # ── Ingredients ─────────────────────────────────────────────────────────────── @@ -64,39 +87,108 @@ class ProductIngredientOut(ProductIngredientBase): model_config = {"from_attributes": True} -# ── Preferences ─────────────────────────────────────────────────────────────── +# ── Sub-choices (nested under a preference choice) ──────────────────────────── -class PreferenceChoiceBase(BaseModel): +class SubChoice(BaseModel): name: str extra_cost: float = 0.0 + is_default: bool = False -class PreferenceChoiceCreate(PreferenceChoiceBase): - pass +# ── Shared subset (set-level, shown for all non-disabling choices) ───────────── + +class SharedSubsetChoice(BaseModel): + name: str + extra_cost: float = 0.0 + is_default: bool = False -class PreferenceChoiceOut(PreferenceChoiceBase): +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 + + +class PreferenceChoiceOut(BaseModel): id: int set_id: int + name: str + extra_cost: float = 0.0 + sub_choices: List[SubChoice] = [] + disables_subset: 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, + } + return data -class PreferenceSetBase(BaseModel): + +class PreferenceSetCreate(BaseModel): name: str - - -class PreferenceSetCreate(PreferenceSetBase): choices: List[PreferenceChoiceCreate] = [] + default_choice_index: Optional[int] = None # index into choices (0-based) + shared_subset: Optional[SharedSubset] = None -class PreferenceSetOut(PreferenceSetBase): +class PreferenceSetOut(BaseModel): id: int product_id: int + name: str choices: List[PreferenceChoiceOut] = [] + default_choice_id: Optional[int] = None + shared_subset: Optional[SharedSubset] = None 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, + } + return data + # ── Products ────────────────────────────────────────────────────────────────── @@ -106,6 +198,7 @@ class ProductBase(BaseModel): base_price: float is_available: bool = True printer_zone_id: Optional[int] = None + sort_order: int = 0 class ProductCreate(ProductBase): @@ -120,11 +213,17 @@ class ProductUpdate(BaseModel): base_price: Optional[float] = None is_available: Optional[bool] = None printer_zone_id: Optional[int] = None + sort_order: Optional[int] = None options: Optional[List[ProductOptionCreate]] = None ingredients: Optional[List[ProductIngredientCreate]] = None preference_sets: Optional[List[PreferenceSetCreate]] = None +class ProductReorderItem(BaseModel): + id: int + sort_order: int + + class ProductOut(ProductBase): id: int options: List[ProductOptionOut] = []