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:
2026-04-24 09:27:16 +03:00
parent 5dbb775308
commit 2c9276e654
4 changed files with 182 additions and 29 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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")
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 product.is_available = False
db.commit() db.commit()

View File

@@ -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] = []