Backend: product sub-choices, sort_order, preference shared_subset, hard-delete
- ProductOption and ProductPreferenceChoice gain sub_choices (JSON Text column)
for nested inline choices shown when the parent is selected
- ProductPreferenceSet gains default_choice_id and shared_subset (set-level
sub-choice group shown for all choices that don't disable it)
- Product gains sort_order column; list endpoint orders by sort_order
- New PUT /products/reorder endpoint for drag-and-drop ordering
- DELETE /products/{id} now accepts ?hard=true for permanent deletion (blocked
if product appears in any past order)
- Schemas updated with model_validators to parse stored JSON back to typed objects
- Add python-multipart to requirements (needed for file upload form parsing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 sqlalchemy.orm import relationship
|
||||||
from database import Base
|
from database import Base
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ class Product(Base):
|
|||||||
is_available = Column(Boolean, default=True, nullable=False)
|
is_available = Column(Boolean, default=True, nullable=False)
|
||||||
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
||||||
image_url = Column(String, nullable=True)
|
image_url = Column(String, nullable=True)
|
||||||
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
category = relationship("Category", back_populates="products")
|
category = relationship("Category", back_populates="products")
|
||||||
printer_zone = relationship("Printer", 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)
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
extra_cost = Column(Float, default=0.0)
|
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")
|
product = relationship("Product", back_populates="options")
|
||||||
|
|
||||||
@@ -61,6 +64,10 @@ class ProductPreferenceSet(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
name = Column(String, 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")
|
product = relationship("Product", back_populates="preference_sets")
|
||||||
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
|
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)
|
set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
extra_cost = Column(Float, default=0.0)
|
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")
|
set = relationship("ProductPreferenceSet", back_populates="choices")
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ Pillow==10.4.0
|
|||||||
bcrypt==4.2.0
|
bcrypt==4.2.0
|
||||||
pyjwt==2.9.0
|
pyjwt==2.9.0
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
|
python-multipart==0.0.9
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
|
from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
|
||||||
|
from models.order import OrderItem
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from schemas.product import (
|
from schemas.product import (
|
||||||
ProductCreate, ProductUpdate, ProductOut,
|
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
|
||||||
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
||||||
PreferenceSetCreate,
|
PreferenceSetCreate,
|
||||||
)
|
)
|
||||||
@@ -24,7 +26,13 @@ def _replace_options(db, product, options):
|
|||||||
db.delete(opt)
|
db.delete(opt)
|
||||||
db.flush()
|
db.flush()
|
||||||
for opt in options:
|
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):
|
def _replace_ingredients(db, product, ingredients):
|
||||||
@@ -40,11 +48,29 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
|||||||
db.delete(ps)
|
db.delete(ps)
|
||||||
db.flush()
|
db.flush()
|
||||||
for ps in sets:
|
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.add(new_set)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
created_choices = []
|
||||||
for ch in ps.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 ────────────────────────────────────────────────────────────────
|
# ── Categories ────────────────────────────────────────────────────────────────
|
||||||
@@ -101,25 +127,32 @@ def list_products(all: bool = False, db: Session = Depends(get_db), user: User =
|
|||||||
q = db.query(Product)
|
q = db.query(Product)
|
||||||
if not all or user.role not in ("manager", "sysadmin"):
|
if not all or user.role not in ("manager", "sysadmin"):
|
||||||
q = q.filter(Product.is_available == True)
|
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)
|
@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)):
|
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
data = body.model_dump(exclude={"options", "ingredients", "preference_sets"})
|
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)
|
product = Product(**data)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
db.flush()
|
db.flush()
|
||||||
for opt in body.options:
|
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:
|
for ing in body.ingredients:
|
||||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||||
for ps in body.preference_sets:
|
_replace_preference_sets(db, product, 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()))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(product)
|
db.refresh(product)
|
||||||
return 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)
|
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||||
|
|
||||||
# Delete old image if exists
|
|
||||||
if product.image_url:
|
if product.image_url:
|
||||||
old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url))
|
old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url))
|
||||||
if os.path.exists(old_path):
|
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)
|
@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()
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
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()
|
db.commit()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from pydantic import BaseModel
|
import json
|
||||||
from typing import Optional, List
|
from pydantic import BaseModel, model_validator, field_validator
|
||||||
|
from typing import Optional, List, Any
|
||||||
|
|
||||||
|
|
||||||
class CategoryCreate(BaseModel):
|
class CategoryCreate(BaseModel):
|
||||||
@@ -30,21 +31,43 @@ class CategoryReorderItem(BaseModel):
|
|||||||
|
|
||||||
# ── Options ──────────────────────────────────────────────────────────────────
|
# ── Options ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class OptionSubChoice(BaseModel):
|
||||||
|
name: str
|
||||||
|
extra_cost: float = 0.0
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ProductOptionBase(BaseModel):
|
class ProductOptionBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
extra_cost: float = 0.0
|
extra_cost: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class ProductOptionCreate(ProductOptionBase):
|
class ProductOptionCreate(ProductOptionBase):
|
||||||
pass
|
sub_choices: List[OptionSubChoice] = []
|
||||||
|
|
||||||
|
|
||||||
class ProductOptionOut(ProductOptionBase):
|
class ProductOptionOut(ProductOptionBase):
|
||||||
id: int
|
id: int
|
||||||
product_id: int
|
product_id: int
|
||||||
|
sub_choices: List[OptionSubChoice] = []
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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 ───────────────────────────────────────────────────────────────
|
# ── Ingredients ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -64,39 +87,108 @@ class ProductIngredientOut(ProductIngredientBase):
|
|||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
# ── Preferences ───────────────────────────────────────────────────────────────
|
# ── Sub-choices (nested under a preference choice) ────────────────────────────
|
||||||
|
|
||||||
class PreferenceChoiceBase(BaseModel):
|
class SubChoice(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
extra_cost: float = 0.0
|
extra_cost: float = 0.0
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
class PreferenceChoiceCreate(PreferenceChoiceBase):
|
# ── Shared subset (set-level, shown for all non-disabling choices) ─────────────
|
||||||
pass
|
|
||||||
|
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
|
id: int
|
||||||
set_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_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
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class PreferenceSetCreate(PreferenceSetBase):
|
|
||||||
choices: List[PreferenceChoiceCreate] = []
|
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
|
id: int
|
||||||
product_id: int
|
product_id: int
|
||||||
|
name: str
|
||||||
choices: List[PreferenceChoiceOut] = []
|
choices: List[PreferenceChoiceOut] = []
|
||||||
|
default_choice_id: Optional[int] = None
|
||||||
|
shared_subset: Optional[SharedSubset] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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 ──────────────────────────────────────────────────────────────────
|
# ── Products ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -106,6 +198,7 @@ class ProductBase(BaseModel):
|
|||||||
base_price: float
|
base_price: float
|
||||||
is_available: bool = True
|
is_available: bool = True
|
||||||
printer_zone_id: Optional[int] = None
|
printer_zone_id: Optional[int] = None
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(ProductBase):
|
class ProductCreate(ProductBase):
|
||||||
@@ -120,11 +213,17 @@ class ProductUpdate(BaseModel):
|
|||||||
base_price: Optional[float] = None
|
base_price: Optional[float] = None
|
||||||
is_available: Optional[bool] = None
|
is_available: Optional[bool] = None
|
||||||
printer_zone_id: Optional[int] = None
|
printer_zone_id: Optional[int] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
options: Optional[List[ProductOptionCreate]] = None
|
options: Optional[List[ProductOptionCreate]] = None
|
||||||
ingredients: Optional[List[ProductIngredientCreate]] = None
|
ingredients: Optional[List[ProductIngredientCreate]] = None
|
||||||
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductReorderItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
sort_order: int
|
||||||
|
|
||||||
|
|
||||||
class ProductOut(ProductBase):
|
class ProductOut(ProductBase):
|
||||||
id: int
|
id: int
|
||||||
options: List[ProductOptionOut] = []
|
options: List[ProductOptionOut] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user